[
  {
    "path": ".gemini/skills/code-reviewer/SKILL.md",
    "content": "---\nname: code-reviewer\ndescription:\n  Use this skill to review code. It supports both local changes (staged or\n  working tree) and remote Pull Requests (by ID or URL). It focuses on\n  correctness, maintainability, and adherence to project standards.\n---\n\n# Code Reviewer\n\nThis skill guides the agent in conducting professional and thorough code reviews\nfor both local development and remote Pull Requests.\n\n## Workflow\n\n### 1. Determine Review Target\n\n- **Remote PR**: If the user provides a PR number or URL (e.g., \"Review PR\n  #123\"), target that remote PR.\n- **Local Changes**: If no specific PR is mentioned, or if the user asks to\n  \"review my changes\", target the current local file system states (staged and\n  unstaged changes).\n\n### 2. Preparation\n\n#### For Remote PRs:\n\n1.  **Checkout**: Use the GitHub CLI to checkout the PR.\n    ```bash\n    gh pr checkout <PR_NUMBER>\n    ```\n2.  **Verification**: Execute the workspace's verification suite to catch issues\n    early. Capture the output of these commands to inform your review (e.g.,\n    note any failed tests or linting errors).\n    ```bash\n    npm install\n    npm run build\n    npm run test\n    npm run lint\n    npm run format:check\n    ```\n3.  **Context**: Read the PR description and any existing comments to understand\n    the goal and history.\n\n#### For Local Changes:\n\n1.  **Identify Changes**:\n    - Check status: `git status`\n    - Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged).\n2.  **Verification**: Ask the user if they want to run the verification suite\n    before reviewing. If yes, run the same commands as for remote PRs.\n\n### 3. In-Depth Analysis\n\nAnalyze the code changes based on the following pillars:\n\n- **Correctness**: Does the code achieve its stated purpose without bugs or\n  logical errors?\n- **Maintainability**: Is the code clean, well-structured, and easy to\n  understand and modify in the future? Consider factors like code clarity,\n  modularity, and adherence to established design patterns.\n- **Readability**: Is the code well-commented (where necessary) and consistently\n  formatted according to our project's coding style guidelines?\n- **Efficiency**: Are there any obvious performance bottlenecks or resource\n  inefficiencies introduced by the changes?\n- **Security**: Are there any potential security vulnerabilities or insecure\n  coding practices?\n- **Edge Cases and Error Handling**: Does the code appropriately handle edge\n  cases and potential errors?\n- **Testability**: Is the new or modified code adequately covered by tests (even\n  if preflight checks pass)? Suggest additional test cases that would improve\n  coverage or robustness.\n\n### 4. Draft Feedback (DO NOT FIX)\n\n**IMPORTANT**: You are a reviewer, NOT a fixer. Do NOT attempt to fix the code\nyourself. Your goal is to provide high-quality feedback.\n\n#### Structure\n\n- **Summary**: A high-level overview of the review.\n- **Verification Results**: briefly summarize the results of the build, test,\n  lint, and format checks.\n- **Findings**:\n  - **Critical**: Bugs, security issues, test failures, lint errors, or breaking\n    changes.\n  - **Improvements**: Suggestions for better code quality or performance.\n  - **Nitpicks**: Formatting or minor style issues (optional).\n- **Conclusion**: Clear recommendation (Approved / Request Changes).\n\n#### Tone\n\n- Be constructive, professional, and friendly.\n- Explain _why_ a change is requested.\n- For approvals, acknowledge the specific value of the contribution.\n\n### 5. Interactive Refinement\n\n1.  **Present Draft**: Show the drafted review feedback to the user.\n2.  **Solicit Input**: Ask the user for their thoughts.\n    - \"Does this feedback look accurate?\"\n    - \"Is the tone appropriate?\"\n    - \"Did I miss any context?\"\n3.  **Iterate**: If the user provides suggestions or corrections, update the\n    draft feedback accordingly.\n4.  **Finalize**: specific approval from the user is not strictly required if\n    they don't have further comments, but ensure they have a chance to respond.\n\n### 6. Cleanup (Remote PRs only)\n\n- After the review is complete, ask the user if they want to switch back to the\n  default branch (e.g., `main` or `master`).\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: 'npm'\n    directory: '/'\n    schedule:\n      interval: 'weekly'\n    open-pull-requests-limit: 5\n    groups:\n      typescript:\n        patterns:\n          - 'typescript'\n      npm-root:\n        patterns:\n          - '*'\n    labels:\n      - 'dependencies'\n      - 'npm'\n    commit-message:\n      prefix: 'chore'\n      include: 'scope'\n\n  - package-ecosystem: 'npm'\n    directory: '/workspace-server'\n    schedule:\n      interval: 'weekly'\n    open-pull-requests-limit: 5\n    groups:\n      npm-workspace-server:\n        patterns:\n          - '*'\n    labels:\n      - 'dependencies'\n      - 'npm'\n    commit-message:\n      prefix: 'chore'\n      include: 'scope'\n\n  - package-ecosystem: 'github-actions'\n    directory: '/'\n    schedule:\n      interval: 'weekly'\n    open-pull-requests-limit: 5\n    groups:\n      github-actions:\n        patterns:\n          - '*'\n    labels:\n      - 'dependencies'\n      - 'github-actions'\n    commit-message:\n      prefix: 'chore'\n      include: 'scope'\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n    branches: [main]\n\njobs:\n  verify:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Use Node.js 20.x\n        uses: actions/setup-node@v6\n        with:\n          node-version: '20.x'\n          cache: 'npm'\n          cache-dependency-path: package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run linter\n        run: npm run lint\n\n      - name: Run Prettier check\n        run: npm run format:check\n\n      - name: Run type checking\n        run: npx tsc --noEmit --project workspace-server\n\n  test:\n    needs: verify\n    runs-on: ${{ matrix.os }}\n\n    strategy:\n      matrix:\n        node-version: [20.x, 22.x, 24.x]\n        os: [ubuntu-latest, windows-latest, macos-latest]\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: 'npm'\n          cache-dependency-path: package-lock.json\n\n      - name: Install libsecret (Linux)\n        if: runner.os == 'Linux'\n        run: sudo apt-get update && sudo apt-get install -y libsecret-1-0\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests with coverage\n        run: npm run test:ci\n\n      - name: Upload coverage to Codecov\n        uses: codecov/codecov-action@v6\n        with:\n          directory: ./workspace-server/coverage\n          flags: unittests\n          name: codecov-umbrella\n          fail_ci_if_error: false\n\n  build:\n    runs-on: ubuntu-latest\n    needs: test\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Use Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '20.x'\n          cache: 'npm'\n          cache-dependency-path: package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build\n        run: npm run build\n\n      - name: Upload build artifacts\n        uses: actions/upload-artifact@v7\n        with:\n          name: dist\n          path: workspace-server/dist/\n\n  security:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Run security audit\n        run: npm audit --audit-level=moderate\n        continue-on-error: true\n\n      - name: Check for known vulnerabilities\n        run: npx audit-ci --moderate\n        continue-on-error: true\n"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "content": "name: Deploy Docs to GitHub Pages\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write # For actions/checkout to fetch code\n      pages: write # For uploading the artifact\n      id-token: write # For OIDC authentication\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0 # Not strictly needed but good practice for some build tools\n\n      - name: Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 20\n          cache: npm # Cache npm dependencies\n\n      - name: Install dependencies\n        run: npm install\n\n      - name: Build docs\n        run: npm run docs:build\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v5\n        with:\n          path: docs/.vitepress/dist # The directory where VitePress builds the docs\n\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    needs: build\n    permissions:\n      pages: write\n      id-token: write\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v5\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    strategy:\n      matrix:\n        include:\n          - os: ubuntu-latest\n            platform: linux\n          - os: macos-latest\n            platform: darwin\n          - os: windows-latest\n            platform: win32\n    runs-on: ${{ matrix.os }}\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build extension\n        run: npm run build --workspace=workspace-server\n\n      - name: Create release assets\n        run: npm run release -- --platform=${{ matrix.platform }}\n\n      - name: Release\n        uses: softprops/action-gh-release@v3\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz\n"
  },
  {
    "path": ".github/workflows/weekly-preview.yml",
    "content": "name: Weekly Preview Release\n\non:\n  schedule:\n    # Every Monday at 09:00 UTC\n    - cron: '0 9 * * 1'\n  workflow_dispatch:\n\njobs:\n  prepare:\n    runs-on: ubuntu-latest\n    outputs:\n      tag_name: ${{ steps.date.outputs.tag_name }}\n    steps:\n      - name: Get current date\n        id: date\n        run: echo \"tag_name=preview-$(date +'%Y-%m-%d')\" >> $GITHUB_OUTPUT\n\n  create-release:\n    needs: prepare\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Create Release\n        uses: softprops/action-gh-release@v3\n        with:\n          tag_name: ${{ needs.prepare.outputs.tag_name }}\n          name: Weekly Preview ${{ needs.prepare.outputs.tag_name }}\n          prerelease: true\n\n  release:\n    needs: [prepare, create-release]\n    name: Build and Release\n    strategy:\n      matrix:\n        include:\n          - os: ubuntu-latest\n            platform: linux\n          - os: macos-latest\n            platform: darwin\n          - os: windows-latest\n            platform: win32\n    runs-on: ${{ matrix.os }}\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build extension\n        run: npm run build --workspace=workspace-server\n\n      - name: Create release assets\n        shell: bash\n        env:\n          GITHUB_REF_NAME: ${{ needs.prepare.outputs.tag_name }}\n        run: npm run release -- --platform=${{ matrix.platform }}\n\n      - name: Upload Release Assets\n        uses: softprops/action-gh-release@v3\n        with:\n          tag_name: ${{ needs.prepare.outputs.tag_name }}\n          files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz\n"
  },
  {
    "path": ".gitignore",
    "content": "# 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\n\n# Logs\n*.log\n\n# Coverage\ncoverage/\n\n# Editor files\n*.swp\n*.swo\n*~\n.idea/\n.vscode/\n\n# Auth tokens\ntoken.json\ngemini-cli-workspace-token.json\n.gemini-cli-workspace-master-key\n\ncommit_message.txt\n\n# Release directory\nrelease/\n\n# VitePress\ndocs/.vitepress/dist\ndocs/.vitepress/cache"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\ndist\ncoverage\n.vitepress/cache\n.vitepress/dist\npackage-lock.json\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"trailingComma\": \"all\",\n  \"singleQuote\": true,\n  \"printWidth\": 80,\n  \"tabWidth\": 2,\n  \"overrides\": [\n    {\n      \"files\": [\"**/*.md\"],\n      \"options\": {\n        \"proseWrap\": \"always\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to Contribute\n\nWe would love to accept your patches and contributions to this project.\n\n## Before you begin\n\n### Sign our Contributor License Agreement\n\nContributions to this project must be accompanied by a\n[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).\nYou (or your employer) retain the copyright to your contribution; this simply\ngives us permission to use and redistribute your contributions as part of the\nproject.\n\nIf you or your current employer have already signed the Google CLA (even if it\nwas for a different project), you probably don't need to do it again.\n\nVisit <https://cla.developers.google.com/> to see your current agreements or to\nsign a new one.\n\n### Review our Community Guidelines\n\nThis project follows\n[Google's Open Source Community Guidelines](https://opensource.google/conduct/).\n\n## Contribution Process\n\n### Code Reviews\n\nAll submissions, including submissions by project members, require review. We\nuse [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)\nfor this purpose.\n\n### Self Assigning Issues\n\nIf you're looking for an issue to work on, check out our list of issues that are\nlabeled\n[\"help wanted\"](https://github.com/gemini-cli-extensions/workspace/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22).\n\nTo assign an issue to yourself, simply add a comment with the text `/assign`.\nThe comment must contain only that text and nothing else. This command will\nassign the issue to you, provided it is not already assigned.\n\nPlease note that you can have a maximum of 3 issues assigned to you at any given\ntime.\n\n### Pull Request Guidelines\n\nTo help us review and merge your PRs quickly, please follow these guidelines.\nPRs that do not meet these standards may be closed.\n\n#### 1. Link to an Existing Issue\n\nAll PRs should be linked to an existing issue in our tracker. This ensures that\nevery change has been discussed and is aligned with the project's goals before\nany code is written.\n\n- **For bug fixes:** The PR should be linked to the bug report issue.\n- **For features:** The PR should be linked to the feature request or proposal\n  issue that has been approved by a maintainer.\n\nIf an issue for your change doesn't exist, please **open one first** and wait\nfor feedback before you start coding.\n\n#### 2. Keep It Small and Focused\n\nWe favor small, atomic PRs that address a single issue or add a single,\nself-contained feature.\n\n- **Do:** Create a PR that fixes one specific bug or adds one specific feature.\n- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature,\n  and a refactor) into a single PR.\n\nLarge changes should be broken down into a series of smaller, logical PRs that\ncan be reviewed and merged independently.\n\n#### 3. Use Draft PRs for Work in Progress\n\nIf you'd like to get early feedback on your work, please use GitHub's **Draft\nPull Request** feature. This signals to the maintainers that the PR is not yet\nready for a formal review but is open for discussion and initial feedback.\n\n#### 4. Ensure All Checks Pass\n\nBefore submitting your PR, ensure that all automated checks are passing by\nrunning:\n\n```bash\nnpm run test && npm run lint && npm run format:check && npx tsc --noEmit --project workspace-server\n```\n\nThis command runs all tests, linting, formatting, and type checks.\n\n#### 5. Write Clear Commit Messages and a Good PR Description\n\nYour PR should have a clear, descriptive title and a detailed description of the\nchanges. Follow the [Conventional Commits](https://www.conventionalcommits.org/)\nstandard for your commit messages.\n\n- **Good PR Title:** `feat(cli): Add --json flag to 'config get' command`\n- **Bad PR Title:** `Made some changes`\n\nIn the PR description, explain the \"why\" behind your changes and link to the\nrelevant issue (e.g., `Fixes #123`).\n\n## Development Setup and Workflow\n\nFor information on how to build, modify, and understand the development setup of\nthis project, please see the [development documentation](docs/development.md).\n"
  },
  {
    "path": "GEMINI.md",
    "content": "This is a Gemini extension that provides tools for interacting with Google\nWorkspace services like Google Docs.\n\n### Building and Running\n\n- **Install dependencies:** `npm install`\n- **Build the project:** `npm run build --prefix workspace-server`\n\n### Development Conventions\n\nThis project uses TypeScript and the Model Context Protocol (MCP) SDK to create\na Gemini extension. The main entry point is `src/index.ts`, which initializes\nthe MCP server and registers the available tools.\n\nThe business logic for each service is separated into its own file in the\n`src/services` directory. For example, `src/services/DocsService.ts` contains\nthe logic for interacting with the Google Docs API.\n\nAuthentication is handled by the `src/auth/AuthManager.ts` file, which uses the\n`@google-cloud/local-auth` library to obtain and refresh OAuth 2.0 credentials.\n\n### Adding New Tools\n\nTo add a new tool, you need to:\n\n1.  Add a new method to the appropriate service file in `src/services`.\n2.  In `src/index.ts`, register the new tool with the MCP server by calling\n    `server.registerTool()`. You will need to provide a name for the tool, a\n    description, and the input schema using the `zod` library.\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "README.md",
    "content": "# Google Workspace Extension for Gemini CLI\n\n[![Build Status](https://github.com/gemini-cli-extensions/workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/gemini-cli-extensions/workspace/actions/workflows/ci.yml)\n\nThe Google Workspace extension for Gemini CLI brings the power of your Google\nWorkspace apps to your command line. Manage your documents, spreadsheets,\npresentations, emails, chat, and calendar events without leaving your terminal.\n\n## Prerequisites\n\nBefore using the Google Workspace extension, you need to be logged into your\nGoogle account.\n\n## Installation\n\nInstall the Google Workspace extension by running the following command from\nyour terminal:\n\n```bash\ngemini extensions install https://github.com/gemini-cli-extensions/workspace\n```\n\n## Usage\n\nOnce the extension is installed, you can use it to interact with your Google\nWorkspace apps. Here are a few examples:\n\n**Create a new Google Doc:**\n\n> \"Create a new Google Doc with the title 'My New Doc' and the content '# My New\n> Document\\n\\nThis is a new document created from the command line.'\"\n\n**List your upcoming calendar events:**\n\n> \"What's on my calendar for today?\"\n\n**Search for a file in Google Drive:**\n\n> \"Find the file named 'my-file.txt' in my Google Drive.\"\n\n## Commands\n\nThis extension provides a variety of commands. Here are a few examples:\n\n### Get Schedule\n\n**Command:** `/calendar:get-schedule [date]`\n\nShows your schedule for today or a specified date.\n\n### Search Drive\n\n**Command:** `/drive:search <query>`\n\nSearches your Google Drive for files matching the given query.\n\n## Headless / Remote Environments\n\nIf you're using the extension over SSH, WSL, Cloud Shell, or another environment\nwithout a local browser, you can authenticate using the headless login tool:\n\n```bash\nnpm run auth-utils -- login\n```\n\nThis prints an OAuth URL you can open in any browser (local machine, phone,\netc.). After signing in, paste the credentials JSON into the CLI. Credentials\nare read securely from `/dev/tty` and are never exposed to the AI model. See the\n[development docs](docs/development.md#headless--remote-environments) for more\ndetails.\n\n## Deployment\n\nIf you want to host your own version of this extension's infrastructure, see the\n[GCP Recreation Guide](docs/GCP-RECREATION.md).\n\n## Resources\n\n- [Documentation](docs/index.md): Detailed documentation on all the available\n  tools.\n- [GitHub Issues](https://github.com/gemini-cli-extensions/workspace/issues):\n  Report bugs or request features.\n\n## Important security consideration: Indirect Prompt Injection Risk\n\nWhen exposing any language model to untrusted data, there's a risk of an\n[indirect prompt injection attack](https://en.wikipedia.org/wiki/Prompt_injection).\nAgentic tools like Gemini CLI, connected to MCP servers, have access to a wide\narray of tools and APIs.\n\nThis MCP server grants the agent the ability to read, modify, and delete your\nGoogle Account data, as well as other data shared with you.\n\n- Never use this with untrusted tools\n- Never include untrusted inputs into the model context. This includes asking\n  Gemini CLI to process mail, documents, or other resources from unverified\n  sources.\n- Untrusted inputs may contain hidden instructions that could hijack your CLI\n  session. Attackers can then leverage this to modify, steal, or destroy your\n  data.\n- Always carefully review actions taken by Gemini CLI on your behalf to ensure\n  they are correct and align with your intentions.\n\n## Contributing\n\nContributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md)\nfile for details on how to contribute to this project.\n\n## 📄 Legal\n\n- **License**: [Apache License 2.0](LICENSE)\n- **Terms of Service**: [Terms of Service](https://policies.google.com/terms)\n- **Privacy Policy**: [Privacy Policy](https://policies.google.com/privacy)\n- **Security**: [Security Policy](SECURITY.md)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Reporting Security Issues\n\nTo report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).\nWe use g.co/vulnz for our intake, and do coordination and disclosure here on\nGitHub (including using GitHub Security Advisory). The Google Security Team will\nrespond within 5 working days of your report on g.co/vulnz.\n"
  },
  {
    "path": "cloud_function/index.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Import required packages\nconst functions = require('@google-cloud/functions-framework');\nconst { SecretManagerServiceClient } = require('@google-cloud/secret-manager');\nconst axios = require('axios');\nconst { URL } = require('node:url');\n\n// --- Configuration loaded from Environment Variables ---\n// These are set in the Google Cloud Function's configuration.\n// They may be absent on the initial deploy (before OAuth credentials exist)\n// and are set on the final deploy. Validation happens at request time.\nconst CLIENT_ID = process.env.CLIENT_ID;\nconst SECRET_NAME = process.env.SECRET_NAME;\nconst REDIRECT_URI = process.env.REDIRECT_URI;\n\n// --- Configuration for local storage (used in instructions) ---\nconst KEYCHAIN_SERVICE_NAME = 'gemini-cli-workspace-oauth';\nconst KEYCHAIN_ACCOUNT_NAME = 'main-account';\n// --- END CONFIGURATION ---\n\n// Initialize the Secret Manager client\nconst secretClient = new SecretManagerServiceClient();\n\n/**\n * Helper function to access a secret from Secret Manager.\n */\nasync function getClientSecret() {\n  try {\n    const [version] = await secretClient.accessSecretVersion({\n      name: SECRET_NAME,\n    });\n    const payload = version.payload.data.toString('utf8');\n\n    return payload;\n  } catch (error) {\n    console.error('Failed to access secret version:', error);\n    throw new Error('Could not retrieve client secret.');\n  }\n}\n\n/**\n * Handles the OAuth 2.0 callback.\n * @param {Object} req Express request object.\n * @param {Object} res Express response object.\n */\nasync function handleCallback(req, res) {\n  const code = req.query.code;\n  const state = req.query.state; // The state is the base64 encoded local redirect URI\n\n  if (!code) {\n    console.error('Missing authorization code in request query parameters.');\n    return res.status(400).send('Error: Missing authorization code.');\n  }\n\n  try {\n    const clientSecret = await getClientSecret();\n    const tokenResponse = await axios.post(\n      'https://oauth2.googleapis.com/token',\n      {\n        client_id: CLIENT_ID,\n        client_secret: clientSecret,\n        code: code,\n        grant_type: 'authorization_code',\n        redirect_uri: REDIRECT_URI,\n      },\n    );\n\n    const { access_token, refresh_token, expires_in, scope, token_type } =\n      tokenResponse.data;\n\n    // Calculate expiry_date (timestamp in milliseconds)\n    const expiry_date = Date.now() + expires_in * 1000;\n\n    // If state is present, decode it and decide whether to redirect or show manual page.\n    if (state) {\n      try {\n        // SECURITY: Enforce a reasonable size limit on the state parameter to prevent DoS.\n        if (state.length > 4096) {\n          throw new Error('State parameter exceeds size limit of 4KB.');\n        }\n\n        const payload = JSON.parse(\n          Buffer.from(state, 'base64').toString('utf8'),\n        );\n\n        // If not in manual mode and a URI is present, perform the redirect.\n        if (payload && payload.manual === false && payload.uri) {\n          const redirectUrl = new URL(payload.uri);\n\n          // SECURITY: Validate the redirect URI to prevent open redirect attacks.\n          if (\n            redirectUrl.hostname !== 'localhost' &&\n            redirectUrl.hostname !== '127.0.0.1'\n          ) {\n            throw new Error(\n              `Invalid redirect hostname: ${redirectUrl.hostname}. Must be localhost or 127.0.0.1.`,\n            );\n          }\n\n          const finalUrl = redirectUrl; // Use the validated URL object\n          finalUrl.searchParams.append('access_token', access_token);\n          if (refresh_token) {\n            finalUrl.searchParams.append('refresh_token', refresh_token);\n          }\n          finalUrl.searchParams.append('scope', scope);\n          finalUrl.searchParams.append('token_type', token_type);\n          finalUrl.searchParams.append('expiry_date', expiry_date.toString());\n\n          // SECURITY: Pass the CSRF token back to the client for validation.\n          if (payload.csrf) {\n            finalUrl.searchParams.append('state', payload.csrf);\n          }\n\n          return res.redirect(302, finalUrl.toString());\n        }\n      } catch (e) {\n        console.error(\n          'Error processing state or redirect. Falling back to manual page.',\n          e,\n        );\n      }\n    }\n\n    // --- Fallback to manual instructions ---\n\n    const credentialsJson = JSON.stringify(\n      {\n        refresh_token: refresh_token,\n        scope: scope,\n        token_type: token_type,\n        access_token: access_token,\n        expiry_date: expiry_date,\n      },\n      null,\n      2,\n    ); // Pretty print JSON\n\n    // 4. Display the JSON and add a copy button + instructions\n    res.set('Content-Type', 'text/html');\n    res.status(200).send(`\n      <html>\n        <head>\n          <title>OAuth Token Generated</title>\n          <style>\n            body { font-family: sans-serif; display: grid; place-items: center; min-height: 90vh; background-color: #f4f7f6; padding: 1rem;}\n            .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; }\n            h1 { color: #333; margin-top: 0;}\n            h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; }\n            textarea {\n              width: 100%;\n              min-height: 150px;\n              padding: 0.5rem;\n              border: 1px solid #ccc;\n              border-radius: 4px;\n              font-family: monospace;\n              white-space: pre;\n              word-break: break-all;\n              box-sizing: border-box; /* Include padding and border in the element's total width and height */\n            }\n            button {\n              display: block;\n              margin: 1rem auto 1rem 0; /* Align left */\n              padding: 0.75rem 1.5rem;\n              font-size: 1rem;\n              border-radius: 4px;\n              border: none;\n              background-color: #4285F4;\n              color: white;\n              cursor: pointer;\n              transition: background-color 0.2s;\n            }\n            button:hover { background-color: #357ae8; }\n            button:active { background-color: #2a65d5; }\n            #copy-status { font-style: italic; color: green; margin-left: 10px; opacity: 0; transition: opacity 0.5s;}\n            .instructions { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee; font-size: 0.9em; }\n            code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; }\n          </style>\n        </head>\n        <body>\n          <div class=\"container\">\n            <h1>Success! Credentials Ready</h1>\n            <p>Copy the JSON block below. You'll need to store this as the password/secret in your operating system's keychain.</p>\n\n            <h3>Credentials JSON</h3>\n            <textarea id=\"credentials-json\" readonly>${credentialsJson}</textarea>\n            <button onclick=\"copyCredentials()\">Copy JSON</button>\n            <span id=\"copy-status\">Copied!</span>\n\n            <div class=\"instructions\">\n              <h4>CLI Login (Recommended):</h4>\n              <p>In your terminal, run:</p>\n              <pre style=\"background:#eee;padding:0.75rem;border-radius:4px;overflow-x:auto;\"><code>node dist/headless-login.js</code></pre>\n              <p>Then paste the JSON above when prompted. The CLI will securely store your credentials.</p>\n\n              <details style=\"margin-top: 1.5rem;\">\n                <summary style=\"cursor:pointer;color:#555;\"><strong>Advanced: Manual Keychain Storage</strong></summary>\n                <div style=\"margin-top: 0.5rem;\">\n                  <ol>\n                    <li>Open your OS Keychain/Credential Manager.</li>\n                    <li>Create a new secure entry (e.g., a \"Generic Password\" on macOS, a \"Windows Credential\", or similar on Linux).</li>\n                    <li>Set the <strong>Service</strong> (or equivalent field) to: <code>${KEYCHAIN_SERVICE_NAME}</code></li>\n                    <li>Set the <strong>Account</strong> (or username field) to: <code>${KEYCHAIN_ACCOUNT_NAME}</code></li>\n                    <li>Paste the copied JSON into the <strong>Password/Secret</strong> field.</li>\n                    <li>Save the entry.</li>\n                  </ol>\n                  <p><small>(If keychain is unavailable, the server falls back to an encrypted file, but keychain is recommended.)</small></p>\n                </div>\n              </details>\n            </div>\n          </div>\n\n          <script>\n            function copyCredentials() {\n              const textArea = document.getElementById('credentials-json');\n              const status = document.getElementById('copy-status');\n\n              // Use modern Clipboard API if available, with fallback to execCommand\n              if (navigator.clipboard && navigator.clipboard.writeText) {\n                navigator.clipboard.writeText(textArea.value).then(() => {\n                  status.textContent = 'Copied!';\n                  status.style.color = 'green';\n                }, () => {\n                  status.textContent = 'Copy failed!';\n                  status.style.color = 'red';\n                });\n              } else {\n                // Fallback for older browsers/iframes without clipboard access\n                textArea.select();\n                try {\n                  const successful = document.execCommand('copy');\n                  if (successful) {\n                    status.textContent = 'Copied!';\n                    status.style.color = 'green';\n                  } else {\n                    status.textContent = 'Copy failed!';\n                    status.style.color = 'red';\n                  }\n                } catch (err) {\n                  status.textContent = 'Copy failed!';\n                  status.style.color = 'red';\n                  console.error('Fallback copy failed: ', err);\n                }\n              }\n\n              status.style.opacity = 1;\n              setTimeout(() => { status.style.opacity = 0; }, 2000);\n\n              // Deselect text after attempting to copy\n              if (window.getSelection) {window.getSelection().removeAllRanges();}\n              else if (document.selection) {document.selection.empty();}\n            }\n          </script>\n        </body>\n      </html>\n    `);\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response) {\n      console.error('Error during token exchange:', error.response.data);\n    } else {\n      console.error(\n        'Error during token exchange:',\n        error instanceof Error ? error.message : error,\n      );\n    }\n    res\n      .status(500)\n      .send(\n        'An error occurred during the token exchange. Check function logs for details.',\n      );\n  }\n}\n\n/**\n * Handles token refresh.\n * Accepts a refresh_token and returns a new access_token.\n * @param {Object} req Express request object.\n * @param {Object} res Express response object.\n */\nasync function handleRefreshToken(req, res) {\n  // Only accept POST requests\n  if (req.method !== 'POST') {\n    console.error('Invalid method for refreshToken:', req.method);\n    return res.status(405).send('Method Not Allowed');\n  }\n\n  const { refresh_token } = req.body;\n\n  if (!refresh_token) {\n    console.error('Missing refresh_token in request body');\n    return res\n      .status(400)\n      .send('Error: Missing refresh_token in request body.');\n  }\n\n  try {\n    const clientSecret = await getClientSecret();\n\n    const tokenResponse = await axios.post(\n      'https://oauth2.googleapis.com/token',\n      {\n        client_id: CLIENT_ID,\n        client_secret: clientSecret,\n        refresh_token: refresh_token,\n        grant_type: 'refresh_token',\n      },\n    );\n\n    const { access_token, expires_in, scope, token_type } = tokenResponse.data;\n\n    // Calculate expiry_date (timestamp in milliseconds)\n    const expiry_date = Date.now() + expires_in * 1000;\n\n    // Return the new credentials\n    // Note: Google does NOT return a new refresh_token on refresh\n    // The client must preserve the original refresh_token\n    res.status(200).json({\n      access_token,\n      expiry_date,\n      token_type,\n      scope,\n    });\n  } catch (error) {\n    if (axios.isAxiosError(error) && error.response) {\n      console.error('Error during token refresh:', error.response.data);\n      res.status(error.response.status).json(error.response.data);\n    } else {\n      console.error(\n        'Error during token refresh:',\n        error instanceof Error ? error.message : error,\n      );\n      res.status(500).send('An error occurred during token refresh.');\n    }\n  }\n}\n\n/**\n * Main entry point for the Cloud Function.\n * Routes requests to either the callback handler or the refresh handler.\n */\nfunctions.http('oauthHandler', async (req, res) => {\n  // Validate required environment variables at request time\n  if (!CLIENT_ID || !SECRET_NAME || !REDIRECT_URI) {\n    return res\n      .status(503)\n      .send(\n        'Function not yet configured. Missing required environment variables: CLIENT_ID, SECRET_NAME, REDIRECT_URI.',\n      );\n  }\n\n  // Route to refresh handler if path ends with /refresh or /refreshToken or it's a POST with refresh_token\n  if (\n    ['/refresh', '/refreshToken'].includes(req.path) ||\n    (req.method === 'POST' && req.body?.refresh_token)\n  ) {\n    return handleRefreshToken(req, res);\n  }\n\n  // Route to callback handler if path ends with /callback or /oauth2callback or has 'code' query param\n  if (['/callback', '/oauth2callback'].includes(req.path) || req.query.code) {\n    return handleCallback(req, res);\n  }\n\n  // Default/Error case\n  res\n    .status(400)\n    .send(\n      'Unknown request type. Expected OAuth callback or token refresh request.',\n    );\n});\n"
  },
  {
    "path": "cloud_function/package.json",
    "content": "{\n  \"name\": \"oauth-handler\",\n  \"version\": \"1.0.0\",\n  \"main\": \"index.js\",\n  \"dependencies\": {\n    \"@google-cloud/functions-framework\": \"^3.0.0\",\n    \"@google-cloud/secret-manager\": \"^5.0.0\",\n    \"axios\": \"^1.15.0\"\n  }\n}\n"
  },
  {
    "path": "commands/calendar/clear-schedule.toml",
    "content": "description = \"Clear all events for a specific date or range by deleting or declining them\"\nprompt = \"\"\"\nPlease help me clear my schedule for a specific date or date range. Follow these steps carefully:\n\n1. **Identify User & Context:**\n   - Call `people.getMe` to get my email address and details.\n   - Call `time.getTimeZone` to get my local time zone.\n\n2. **Determine Date Range:**\n   - Parse the date or date range from the arguments: \"{{args}}\".\n   - If no arguments are provided, use `time.getCurrentDate` to default to today.\n\n3. **Fetch Events:**\n   - Call `calendar.listEvents` for the determined date range using my primary calendar.\n   - **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.\n\n4. **Review & Confirm (CRITICAL):**\n   - List all the events found for that period in a clear, numbered list showing the Time and Title.\n   - **Specifically call out any all-day events** and ask if I want to include those in the clear operation.\n   - **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?\"\n\n5. **Execute Clearing:**\n   - Once I confirm, iterate through each event in the list:\n     - **If I am the organizer** (check `organizer.self` is true or `organizer.email` matches mine):\n       - Call `calendar.deleteEvent` to remove it.\n     - **If I am NOT the organizer**:\n       - Call `calendar.respondToEvent` with `responseStatus` set to \"declined\".\n\n6. **Final Report:**\n   - Summarize the actions taken (e.g., \"Deleted 2 events and declined 3 events.\").\n\"\"\"\n"
  },
  {
    "path": "commands/calendar/get-schedule.toml",
    "content": "description = \"Show your schedule for today, or the date specified\"\nprompt = \"\"\"\nPlease show me my schedule for today. To do that use the following steps:\n\n1) Use the people.getMe tool to get my information.\n2) Use the time.getTimeZone to get my local time zone.\n3) Either use the time.getCurrentDate or the user supplied date here: {{args}} to understand the current date.\n4) Call calendar.list to identify the calendar associated with me from step 1.\n5) Call calendar.listEvents to identify events on my calendar for the date we determined in step 3.\n6) Format the list of events in my local time zone from step 2 in the following format:\n\n> HH:MM - HH:MM: Event Title (attendance status) - Event Location\n> HH:MM - HH:MM: Event 2 Title (attendance status) - Event Location\n\nNote: If a calendar event has conflicting timezone information, prioritize the dateTime field over the timeZone field.\n\n\"\"\"\n"
  },
  {
    "path": "commands/drive/search.toml",
    "content": "description = \"Searches Google Drive for files matching a query.\"\nprompt = \"\"\"\nYou are tasked with searching Google Drive for files.\n\n1) The user's search query is: {{args}}\n2) Call the `drive.search` tool, passing the user's query as the `query` argument.\n3) The `drive.search` tool returns a JSON object containing a list of files.\n4) Format the result into a clear, readable list, showing only the `name` and `id` for each file found.\n5) If no files are found, state that clearly.\n\nExample Output Format:\n> File Name: My Document (ID: 1a2b3c4d5e6f7g8h9i0j)\n> File Name: Project Report (ID: k1l2m3n4o5p6q7r8s9t0)\n\"\"\"\n"
  },
  {
    "path": "commands/gmail/search.toml",
    "content": "description = \"Searches for emails in Gmail matching a query.\"\nprompt = \"\"\"\nYou are tasked with searching Gmail for emails.\n\n1) The user's search query is: {{args}}\n2) Call the `gmail.search` tool, passing the user's query as the `query` argument.\n3) The `gmail.search` tool returns a JSON object containing a list of emails (id and threadId).\n4) For each email found, you MUST call `gmail.get` with the `messageId` and `format='metadata'` to get the From, Subject, and Snippet.\n5) Format the result into a clear, readable list.\n6) If no emails are found, state that clearly.\n\nExample Output Format:\n> From: sender@example.com\n  Subject: Meeting Reminder\n  Snippet: Don't forget about the meeting tomorrow...\n  ---\n> From: newsletter@example.com\n  Subject: Weekly News\n  Snippet: Here are the top stories for this week...\n\"\"\"\n"
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import { defineConfig } from 'vitepress';\n\n// https://vitepress.dev/reference/site-config\nexport default defineConfig({\n  base: '/workspace/',\n  title: 'Gemini Workspace Extension',\n  description: 'Documentation for the Google Workspace Server Extension',\n  themeConfig: {\n    // https://vitepress.dev/reference/default-theme-config\n    nav: [\n      { text: 'Home', link: '/' },\n      { text: 'Configuration', link: '/feature-configuration' },\n      { text: 'Development', link: '/development' },\n      { text: 'Release', link: '/release' },\n      { text: 'Release Notes', link: '/release_notes' },\n    ],\n\n    sidebar: [\n      {\n        text: 'Documentation',\n        items: [\n          { text: 'Overview', link: '/' },\n          { text: 'Feature Configuration', link: '/feature-configuration' },\n          { text: 'Development Guide', link: '/development' },\n          { text: 'GCP Setup Guide', link: '/GCP-RECREATION' },\n          { text: 'Release Guide', link: '/release' },\n          { text: 'Release Notes', link: '/release_notes' },\n        ],\n      },\n    ],\n\n    socialLinks: [\n      {\n        icon: 'github',\n        link: 'https://github.com/gemini-cli-extensions/workspace',\n      },\n    ],\n  },\n});\n"
  },
  {
    "path": "docs/GCP-RECREATION.md",
    "content": "# Recreating the GCP Project\n\nThis guide provides step-by-step instructions to recreate the Google Cloud\nPlatform (GCP) project and infrastructure required for the Google Workspace\nExtension.\n\n## Overview\n\nThe extension uses a \"Hybrid\" OAuth flow for security:\n\n1. **Local Client**: Requests authorization from the user.\n2. **Cloud Function**: Acts as a secure proxy to exchange the authorization code\n   for tokens. It holds the `CLIENT_SECRET` securely in Secret Manager.\n3. **Secret Manager**: Stores the OAuth Client Secret.\n\n## Prerequisites\n\n- A Google Cloud Project with billing enabled.\n- [Google Cloud CLI (gcloud)](https://cloud.google.com/sdk/docs/install)\n  installed and authenticated.\n- Node.js and npm installed.\n\n## Step 1: Run the Automated Setup Script\n\nThe setup script handles the full infrastructure setup in the correct order,\nincluding guided configuration of the OAuth consent screen.\n\n1. Set your project ID:\n   ```bash\n   gcloud config set project YOUR_PROJECT_ID\n   ```\n2. Run the setup script:\n   ```bash\n   ./scripts/setup-gcp.sh\n   ```\n\nThe script will:\n\n1. Enable all required GCP APIs.\n2. Guide you through configuring the **OAuth consent screen** with the required\n   scopes and test users (opens the Cloud Console automatically).\n3. Deploy the Cloud Function and display its URL.\n4. Prompt you to create an **OAuth 2.0 Client ID** in the Google Cloud Console\n   using the deployed function URL as the redirect URI.\n5. Collect your Client ID and Client Secret.\n6. Store the Client Secret in Secret Manager.\n7. Update the Cloud Function with the OAuth configuration.\n8. Grant the Cloud Function access to the secret.\n\n## Step 2: Local Configuration\n\nAfter running the script, set the following environment variables in your shell\n(e.g., in `.zshrc` or `.bashrc`):\n\n```bash\nexport WORKSPACE_CLIENT_ID=\"your-client-id\"\nexport WORKSPACE_CLOUD_FUNCTION_URL=\"https://your-cloud-function-url\"\n```\n\nThe script will display the exact values to use.\n\nAlternatively, you can modify the `DEFAULT_CONFIG` in\n`workspace-server/src/utils/config.ts`.\n\n## Why a Cloud Function?\n\nThe extension uses a Cloud Function to protect your `CLIENT_SECRET`.\n\n- If the `CLIENT_SECRET` were included in the local extension code, anyone with\n  access to the extension could steal it.\n- By using a Cloud Function, the secret stays in your GCP project and is only\n  used server-side during the token exchange.\n- The local client only ever sees the resulting tokens, never the secret.\n"
  },
  {
    "path": "docs/development.md",
    "content": "# Development\n\nThis document provides instructions for developing the Google Workspace\nextension.\n\n## Development Setup and Workflow\n\nThis section guides contributors on how to build, modify, and understand the\ndevelopment setup of this project.\n\n### Setting Up the Development Environment\n\n**Prerequisites:**\n\n1.  **Node.js**:\n    - **Development:** Please use Node.js `~20.19.0`. This specific version is\n      required due to an upstream development dependency issue. You can use a\n      tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions.\n    - **Production:** For running the CLI in a production environment, any\n      version of Node.js `>=20` is acceptable.\n2.  **Git**\n\n### Build Process\n\nTo clone the repository:\n\n```bash\ngit clone https://github.com/gemini-cli-extensions/workspace.git # Or your fork's URL\ncd workspace\n```\n\nTo install dependencies defined in `package.json` as well as root dependencies:\n\n```bash\nnpm install\n```\n\nTo build the entire project (all packages):\n\n```bash\nnpm run build\n```\n\nThis command typically compiles TypeScript to JavaScript, bundles assets, and\nprepares the packages for execution. Refer to `scripts/build.js` and\n`package.json` scripts for more details on what happens during the build.\n\n### Running Tests\n\nThis project contains unit tests.\n\n#### Unit Tests\n\nTo execute the unit test suite for the project:\n\n```bash\nnpm run test\n```\n\nThis will run tests located in the `workspace-server/src/__tests__` directory.\nEnsure tests pass before submitting any changes. For a more comprehensive check,\nit is recommended to run `npm run test && npm run lint`.\n\nTo test a single file, you can pass its path from the project root as an\nargument. For example:\n\n````bash\nnpm run test -- workspace-server/src/__tests__/GmailService.test.ts\n```\n\n### Linting and Style Checks\n\nTo ensure code quality and formatting consistency, run the linter and tests:\n\n```bash\nnpm run test && npm run lint\n````\n\nThis command will run ESLint, Prettier, all tests, and other checks as defined\nin the project's `package.json`.\n\n> [!TIP] After cloning create a git pre-commit hook file to ensure your commits\n> are always clean.\n>\n> ```bash\n> cat <<'EOF' > .git/hooks/pre-commit\n> #!/bin/sh\n> # Run tests and linting before commit\n> if ! (npm run test && npm run lint); then\n>   echo \"Pre-commit checks failed. Commit aborted.\"\n>   exit 1\n> fi\n> EOF\n> chmod +x .git/hooks/pre-commit\n> ```\n\n#### Formatting\n\nTo separately format the code in this project by running the following command\nfrom the root directory:\n\n```bash\nnpm run format\n```\n\nThis command uses Prettier to format the code according to the project's style\nguidelines.\n\n#### Linting\n\nTo separately lint the code in this project, run the following command from the\nroot directory:\n\n```bash\nnpm run lint\n```\n\n#### Testing with Gemini CLI\n\nTo test your code changes with Gemini CLI you can run:\n\n```bash\ngemini extensions uninstall google-workspace\nnpm install && npm run build\ngemini extensions link .\ngemini extensions list\ngemini --debug\n# Prompt to test your feature/bug fix\n```\n\n### Coding Conventions\n\n- Please adhere to the coding style, patterns, and conventions used throughout\n  the existing codebase.\n- Consult\n  [GEMINI.md](https://github.com/gemini-cli-extensions/workspace/blob/main/GEMINI.md)\n  (typically found in the project root) for specific instructions related to\n  AI-assisted development, including conventions for comments, and Git usage.\n- **Imports:** Pay special attention to import paths. The project uses ESLint to\n  enforce restrictions on relative imports between packages.\n\n### Tool Naming\n\nTool names in source use dot notation (e.g., `docs.create`) for logical\ngrouping. By default, these are normalized to underscores at runtime (e.g.,\n`docs_create`) for compatibility with a broader set of applications that use MCP\nincluding Google Antigravity.\n\nWhen the server is run as a Gemini CLI extension the `--use-dot-names` flag is\nused to maintain dot notation and avoid breaking existing configurations.\n\n### Project Structure\n\n- `workspace-server/`: The main workspace for the MCP server.\n  - `src/`: Contains the source code for the server.\n    - `__tests__/`: Contains all the tests.\n    - `auth/`: Handles authentication.\n    - `cli/`: CLI tools (e.g., headless OAuth login).\n    - `features/`: Feature configuration registry and resolver. See the\n      [Feature Configuration](feature-configuration.md) docs.\n    - `services/`: Contains the business logic for each service.\n    - `utils/`: Contains utility functions.\n  - `config/`: Contains configuration files.\n- `scripts/`: Utility scripts for building, testing, and development tasks.\n\n## Authentication\n\nThe extension uses OAuth 2.0 to authenticate with Google Workspace APIs. The\n`scripts/auth-utils.js` script provides a command-line interface to manage\nauthentication credentials.\n\n### Usage\n\nTo use the script, run the following command:\n\n```bash\nnpm run auth-utils -- <command>\n```\n\n### Commands\n\n- `login`: Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell). Reads\n  credentials securely from `/dev/tty` so they are not visible to AI models.\n- `clear`: Clear all authentication credentials.\n- `expire`: Force the access token to expire (for testing refresh).\n- `status`: Show current authentication status.\n- `help`: Show the help message.\n\n### Headless / Remote Environments\n\nIf you are running the server in an environment without a browser (SSH, WSL,\nCloud Shell, VMs), authentication requires manual steps:\n\n1. Run the login tool:\n   ```bash\n   npm run auth-utils -- login\n   ```\n   Or, from the `workspace-server` directory:\n   ```bash\n   node dist/headless-login.js\n   ```\n2. Open the printed OAuth URL in any browser (your local machine, phone, etc.).\n3. Complete Google sign-in. The browser will display a credentials JSON block.\n4. Copy the JSON and paste it into the CLI when prompted.\n\nThe CLI reads input from `/dev/tty` (Unix) or `CON` (Windows) rather than\nprocess stdin, so credentials are never exposed to an AI model that may have\nspawned the process.\n\nUse `--force` to re-authenticate if credentials already exist.\n\n### Token Storage\n\nThe extension uses a **hybrid storage strategy** for OAuth credentials. It first\nattempts to use the OS-level secure storage (via the\n[keytar](https://github.com/atom/node-keytar) library). If the keychain is\nunavailable, it falls back to AES-256-GCM encrypted file storage.\n\nCredentials are stored under the service name `gemini-cli-workspace-oauth` with\nthe account name `main-account`.\n\n#### OS Keychain (Primary)\n\n| Platform    | Backend                               | How to find stored credentials                                                                                                |\n| ----------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| **macOS**   | Keychain Access                       | Open **Keychain Access** → search for `gemini-cli-workspace-oauth`                                                            |\n| **Windows** | Windows Credential Manager            | Start Menu → search **Credential Manager** → **Windows Credentials** → **Generic Credentials** → `gemini-cli-workspace-oauth` |\n| **Linux**   | GNOME Keyring / KWallet (`libsecret`) | Use `secret-tool search service gemini-cli-workspace-oauth` or your desktop's keyring manager                                 |\n\n#### Encrypted File Fallback\n\nWhen the OS keychain is not available (e.g., headless servers, containers, or CI\nenvironments), the extension stores credentials in an encrypted file within the\nextension's installation directory:\n\n| File                               | Purpose                                              |\n| ---------------------------------- | ---------------------------------------------------- |\n| `gemini-cli-workspace-token.json`  | AES-256-GCM encrypted token data                     |\n| `.gemini-cli-workspace-master-key` | 256-bit master key used to derive the encryption key |\n\nBoth files are created with restrictive permissions (`0o600`) and their\ncontaining directory with `0o700`. The encryption key is derived from the master\nkey using `scrypt` with a machine-specific salt.\n\n#### Forcing File Storage\n\nTo bypass the OS keychain and always use encrypted file storage, set the\nenvironment variable:\n\n```bash\nexport GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true\n```\n"
  },
  {
    "path": "docs/feature-configuration.md",
    "content": "# Feature Configuration\n\nThe extension provides a feature configuration system that lets you control\nwhich services and scopes are enabled. Each Google Workspace service is split\ninto **read** and **write** feature groups, giving you granular control over\nwhat the extension can access.\n\n## Feature Groups\n\n| Service    | Group | Scopes                                                                        | Default |\n| ---------- | ----- | ----------------------------------------------------------------------------- | ------- |\n| `docs`     | read  | `documents`                                                                   | ON      |\n| `docs`     | write | `documents`                                                                   | ON      |\n| `drive`    | read  | `drive.readonly`                                                              | ON      |\n| `drive`    | write | `drive`                                                                       | ON      |\n| `calendar` | read  | `calendar.readonly`                                                           | ON      |\n| `calendar` | write | `calendar`                                                                    | ON      |\n| `chat`     | read  | `chat.spaces.readonly`, `chat.messages.readonly`, `chat.memberships.readonly` | ON      |\n| `chat`     | write | `chat.spaces`, `chat.messages`, `chat.memberships`                            | ON      |\n| `gmail`    | read  | `gmail.readonly`                                                              | ON      |\n| `gmail`    | write | `gmail.modify`                                                                | ON      |\n| `people`   | read  | `userinfo.profile`, `directory.readonly`                                      | ON      |\n| `slides`   | read  | `presentations.readonly`                                                      | ON      |\n| `slides`   | write | `presentations`                                                               | **OFF** |\n| `sheets`   | read  | `spreadsheets.readonly`                                                       | ON      |\n| `sheets`   | write | `spreadsheets`                                                                | **OFF** |\n| `time`     | read  | _(none)_                                                                      | ON      |\n| `tasks`    | read  | `tasks.readonly`                                                              | **OFF** |\n| `tasks`    | write | `tasks`                                                                       | **OFF** |\n\n**Read** groups contain tools with no side effects (search, get, list).\n**Write** groups contain tools that perform mutations (create, update, delete,\nsend).\n\nServices whose write scopes aren't in the published GCP project (Slides write,\nSheets write, Tasks) default to **OFF**. These can be enabled by contributors\nusing their own GCP projects.\n\n## Configuration via `WORKSPACE_FEATURE_OVERRIDES`\n\nUse the `WORKSPACE_FEATURE_OVERRIDES` environment variable to enable or disable\nfeature groups and individual tools.\n\n### Syntax\n\n```\nWORKSPACE_FEATURE_OVERRIDES=\"key:on|off,key:on|off,...\"\n```\n\nEach entry is a comma-separated `key:value` pair where:\n\n- `key` is a feature group (e.g., `gmail.write`) or a tool name (e.g.,\n  `calendar.deleteEvent`)\n- `value` is `on` or `off`\n\n### Group-Level Overrides\n\nDisable or enable entire feature groups:\n\n```bash\n# Disable Gmail write tools (send, createDraft, modify, etc.)\nexport WORKSPACE_FEATURE_OVERRIDES=\"gmail.write:off\"\n\n# Disable all of Chat\nexport WORKSPACE_FEATURE_OVERRIDES=\"chat.read:off,chat.write:off\"\n\n# Enable experimental features (Slides write, Tasks)\nexport WORKSPACE_FEATURE_OVERRIDES=\"slides.write:on,tasks.read:on,tasks.write:on\"\n```\n\n### Tool-Level Overrides\n\nDisable specific tools within an enabled group (subtractive only):\n\n```bash\n# Keep calendar.write enabled but disable delete\nexport WORKSPACE_FEATURE_OVERRIDES=\"calendar.deleteEvent:off\"\n\n# Disable destructive Gmail tools while keeping modify/label tools\nexport WORKSPACE_FEATURE_OVERRIDES=\"gmail.send:off,gmail.sendDraft:off\"\n\n# Combine group and tool overrides\nexport WORKSPACE_FEATURE_OVERRIDES=\"gmail.write:off,calendar.deleteEvent:off,slides.write:on\"\n```\n\n::: warning Tool-level overrides are **subtractive only**. You cannot use\n`tool:on` to enable a tool whose feature group is disabled. To enable tools,\nenable their parent feature group. :::\n\n### Precedence\n\nThe configuration follows a three-layer precedence model:\n\n1. **Baked-in defaults** — Current services default ON; experimental services\n   default OFF\n2. **Settings** — Future: overrides from the install-time settings UI\n3. **`WORKSPACE_FEATURE_OVERRIDES`** — Highest precedence; overrides everything\n\n### Effects\n\nWhen a feature group is disabled:\n\n- Its **tools are not registered** with the MCP server (clients won't see them)\n- Its **OAuth scopes are not requested** during authentication\n- If you re-enable a previously disabled feature, you may need to\n  re-authenticate to grant the new scopes\n\n## Tools by Feature Group\n\n### `docs.read`\n\n- `docs.getSuggestions`\n- `docs.getText`\n\n### `docs.write`\n\n- `docs.create`\n- `docs.writeText`\n- `docs.replaceText`\n- `docs.formatText`\n\n### `drive.read`\n\n- `drive.getComments`\n- `drive.findFolder`\n- `drive.search`\n- `drive.downloadFile`\n\n### `drive.write`\n\n- `drive.createFolder`\n- `drive.moveFile`\n- `drive.trashFile`\n- `drive.renameFile`\n\n### `calendar.read`\n\n- `calendar.list`\n- `calendar.listEvents`\n- `calendar.getEvent`\n- `calendar.findFreeTime`\n\n### `calendar.write`\n\n- `calendar.createEvent`\n- `calendar.updateEvent`\n- `calendar.respondToEvent`\n- `calendar.deleteEvent`\n\n### `chat.read`\n\n- `chat.listSpaces`\n- `chat.findSpaceByName`\n- `chat.getMessages`\n- `chat.findDmByEmail`\n- `chat.listThreads`\n\n### `chat.write`\n\n- `chat.sendMessage`\n- `chat.sendDm`\n- `chat.setUpSpace`\n\n### `gmail.read`\n\n- `gmail.search`\n- `gmail.get`\n- `gmail.downloadAttachment`\n- `gmail.listLabels`\n\n### `gmail.write`\n\n- `gmail.modify`\n- `gmail.batchModify`\n- `gmail.modifyThread`\n- `gmail.send`\n- `gmail.createDraft`\n- `gmail.sendDraft`\n- `gmail.createLabel`\n\n### `people.read`\n\n- `people.getUserProfile`\n- `people.getMe`\n- `people.getUserRelations`\n\n### `slides.read`\n\n- `slides.getText`\n- `slides.getMetadata`\n- `slides.getImages`\n- `slides.getSlideThumbnail`\n\n### `sheets.read`\n\n- `sheets.getText`\n- `sheets.getRange`\n- `sheets.getMetadata`\n\n### `time.read`\n\n- `time.getCurrentDate`\n- `time.getCurrentTime`\n- `time.getTimeZone`\n\n## For Contributors\n\nWhen adding a new service or tools:\n\n1. Define read and write feature group entries in\n   `workspace-server/src/features/feature-config.ts`\n2. Set the default state — **ON** for scopes in the published GCP project,\n   **OFF** otherwise\n3. Register your tools in `index.ts` as usual — the feature config wrapper\n   automatically skips disabled tools\n\nThis lets contributors develop and merge new features without being blocked by\nthe published GCP project's scope configuration. Contributors can test with\ntheir own GCP projects by enabling the feature via\n`WORKSPACE_FEATURE_OVERRIDES`.\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Google Workspace Extension Documentation\n\nThis document provides an overview of the Google Workspace extension for Gemini\nCLI.\n\n## Available Tools\n\nThe extension provides the following tools:\n\n### Authentication\n\n- `auth.clear`: Clears the authentication credentials, forcing a re-login on the\n  next request.\n- `auth.refreshToken`: Manually triggers the token refresh process.\n\n### Google Docs\n\n- `docs.create`: Creates a new Google Doc.\n- `docs.getSuggestions`: Retrieves suggested edits from a Google Doc.\n- `docs.getComments`: Retrieves comments from a Google Doc.\n- `docs.writeText`: Writes text to a Google Doc at a specified position.\n- `docs.getText`: Retrieves the text content of a Google Doc.\n- `docs.replaceText`: Replaces all occurrences of a given text with new text in\n  a Google Doc.\n- `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text\n  ranges in a Google Doc.\n\n### Google Slides\n\n- `slides.getText`: Retrieves the text content of a Google Slides presentation.\n- `slides.getMetadata`: Gets metadata about a Google Slides presentation.\n- `slides.getImages`: Downloads all images embedded in a Google Slides\n  presentation to a local directory.\n- `slides.getSlideThumbnail`: Downloads a thumbnail image for a specific slide\n  in a Google Slides presentation to a local path.\n\n### Google Sheets\n\n- `sheets.getText`: Retrieves the content of a Google Sheets spreadsheet.\n- `sheets.getRange`: Gets values from a specific range in a Google Sheets\n  spreadsheet.\n- `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet.\n\n### Google Drive\n\n- `drive.search`: Searches for files and folders in Google Drive.\n- `drive.findFolder`: Finds a folder by name in Google Drive.\n- `drive.createFolder`: Creates a new folder in Google Drive.\n- `drive.downloadFile`: Downloads a file from Google Drive to a local path.\n- `drive.trashFile`: Moves a file or folder to the trash in Google Drive.\n- `drive.renameFile`: Renames a file or folder in Google Drive.\n\n### Google Calendar\n\n- `calendar.list`: Lists all of the user's calendars.\n- `calendar.createEvent`: Creates a new event in a calendar.\n- `calendar.listEvents`: Lists events from a calendar.\n- `calendar.getEvent`: Gets the details of a specific calendar event.\n- `calendar.findFreeTime`: Finds a free time slot for multiple people to meet.\n- `calendar.updateEvent`: Updates an existing event in a calendar.\n- `calendar.respondToEvent`: Responds to a meeting invitation (accept, decline,\n  or tentative).\n- `calendar.deleteEvent`: Deletes an event from a calendar.\n\n### Google Chat\n\n- `chat.listSpaces`: Lists the spaces the user is a member of.\n- `chat.findSpaceByName`: Finds a Google Chat space by its display name.\n- `chat.sendMessage`: Sends a message to a Google Chat space.\n- `chat.getMessages`: Gets messages from a Google Chat space.\n- `chat.sendDm`: Sends a direct message to a user.\n- `chat.findDmByEmail`: Finds a Google Chat DM space by a user's email address.\n- `chat.listThreads`: Lists threads from a Google Chat space in reverse\n  chronological order.\n- `chat.setUpSpace`: Sets up a new Google Chat space with a display name and a\n  list of members.\n\n### Gmail\n\n- `gmail.search`: Search for emails in Gmail using query parameters.\n- `gmail.get`: Get the full content of a specific email message.\n- `gmail.downloadAttachment`: Downloads an attachment from a Gmail message to a\n  local file.\n- `gmail.modify`: Modify a Gmail message.\n- `gmail.batchModify`: Bulk modify up to 1,000 Gmail messages at once.\n- `gmail.modifyThread`: Modify labels on all messages in a Gmail thread.\n- `gmail.send`: Send an email message.\n- `gmail.createDraft`: Create a draft email message.\n- `gmail.sendDraft`: Send a previously created draft email.\n- `gmail.listLabels`: List all Gmail labels in the user's mailbox.\n- `gmail.createLabel`: Create a new Gmail label.\n\n### Time\n\n- `time.getCurrentDate`: Gets the current date. Returns both UTC (for API use)\n  and local time (for user display), along with the timezone.\n- `time.getCurrentTime`: Gets the current time. Returns both UTC (for API use)\n  and local time (for user display), along with the timezone.\n- `time.getTimeZone`: Gets the local timezone.\n\n### People\n\n- `people.getUserProfile`: Gets a user's profile information.\n- `people.getMe`: Gets the profile information of the authenticated user.\n- `people.getUserRelations`: Gets a user's relations (e.g., manager, spouse,\n  assistant). Defaults to the authenticated user and supports filtering by\n  relation type.\n\n## Custom Commands\n\nThe extension includes several pre-configured commands for common tasks:\n\n- `/calendar/get-schedule`: Show your schedule for today, or a specified date.\n- `/calendar/clear-schedule`: Clear all events for a specific date or range by\n  deleting or declining them.\n- `/drive/search`: Searches Google Drive for files matching a query and displays\n  their name and ID.\n- `/gmail/search`: Searches for emails in Gmail matching a query and displays\n  the sender, subject, and snippet.\n\n## Release Notes\n\nSee the [Release Notes](release_notes.md) for details on new features and\nchanges.\n"
  },
  {
    "path": "docs/release.md",
    "content": "# Release Process\n\nThis project uses GitHub Actions to automate the release process.\n\n## Prerequisites\n\n- [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated.\n- Write permissions to the repository.\n\n## Creating a Release\n\nTo streamline the release process:\n\n1.  **Update Version**: Run the `set-version` script to update the version in\n    `package.json` files. The `workspace-server` will now dynamically read its\n    version from its `package.json`.\n\n    ```bash\n    npm run set-version <new-version> #0.0.x for example\n    ```\n\n2.  **Commit Changes**: Commit the version bump and push the changes to `main`\n    (either directly or via a PR).\n\n    ```bash\n    git commit -am \"chore: bump version to <new-version>\"\n    git push origin main\n    ```\n\n3.  **Create Release**: Use the `gh release create` command. This will trigger\n    the GitHub Actions workflow to build the extension and attach the artifacts\n    to the release.\n\n    ```bash\n    # Syntax: gh release create <tag> --generate-notes\n    gh release create v<new-version> --generate-notes\n    ```\n\n### What happens next?\n\n1.  **GitHub Actions Trigger**: The `release.yml` workflow is triggered by the\n    new tag.\n2.  **Build**: The workflow builds the project using `npm run build`.\n3.  **Package**: It creates a `workspace-server.tar.gz` file containing the\n    extension.\n4.  **Upload**: The workflow uploads the tarball to the release you just\n    created.\n\n## Manual Release (Alternative)\n\nIf you prefer not to use the CLI, you can also push a tag manually:\n\n```bash\ngit tag v1.0.0\ngit push origin v1.0.0\n```\n\nThis pushes the tag to GitHub, which triggers the release workflow to create a\nrelease and upload the artifacts. However, using `gh release create` is\nrecommended as it allows you to easily generate release notes.\n"
  },
  {
    "path": "docs/release_notes.md",
    "content": "# Release Notes\n\n## 0.0.8 (2026-05-01)\n\n### New Features\n\n- **Google Calendar**: Added support for `eventType` (Out of Office, Focus Time,\n  Working Location) in Calendar Service.\n- **Feature Configuration**: Introduced a new feature configuration service for\n  scope-based toggles, allowing more granular control over available tools.\n\n### Improvements\n\n- **Authentication**: Refactored OAuth scope management to use a single source\n  of truth and deduplicate read scopes, improving security and consistency.\n- **Google Calendar**: Refactored validation logic to be independent of the\n  service layer.\n- **Google Docs**: Improved `getText` and `getSuggestions` tools to include the\n  document title in their output.\n\n### Fixes\n\n- **Google Docs**: Resolved API errors in `docs.getText`.\n- **Windows Support**: Fixed issues with `npm run clean` and handled `npm.cmd`\n  correctly on Windows.\n- **Documentation**: Fixed various documentation links and improved clarity of\n  API usage.\n\n### Chores\n\n- **Dependencies**: Major update to TypeScript 6.0.3 and various other\n  dependency bumps (Vite 8, Hono 4.12, etc.).\n\n## 0.0.7 (2026-03-11)\n\n### Breaking Changes\n\n- **Google Sheets**: Removed `sheets.find` tool. Use `drive.search` with MIME\n  type filter instead (e.g.,\n  `mimeType='application/vnd.google-apps.spreadsheet'`).\n- **Google Slides**: Removed `slides.find` tool. Use `drive.search` with MIME\n  type filter instead (e.g.,\n  `mimeType='application/vnd.google-apps.presentation'`).\n\n### Improvements\n\n- **Skills**: Renamed all skill directories to `google-*` prefix (e.g.,\n  `calendar` → `google-calendar`) to avoid slash command conflicts.\n- **Calendar Skill**: Added explicit `calendarId='primary'` mandate to prevent\n  agents from omitting the required parameter.\n\n### Skills\n\n- **Sheets Skill**: New skill with `drive.search` guidance for finding\n  spreadsheets.\n- **Slides Skill**: New skill with `drive.search` guidance for finding\n  presentations.\n- **Docs Skill**: Updated with `drive.search` guidance and removed stale\n  `docs.find`/`docs.move` references from skill and documentation.\n\n## 0.0.6 (2026-03-08)\n\n### New Features\n\n- **Google Docs**: Parse rich smart chips (person, date, rich link) in document\n  text output.\n- **Google Docs**: Added `getSuggestions` and `getComments` tools for reading\n  document suggestions and comments.\n- **Google Docs**: Added `formatText` tool for applying rich formatting (bold,\n  italic, headings, etc.) to text ranges.\n- **Google Calendar**: Added Google Meet link generation and Google Drive file\n  attachment support for `createEvent` and `updateEvent`.\n- **Google Calendar**: Added `sendUpdates` parameter to `createEvent` for\n  controlling attendee notifications.\n- **Google Drive**: Added `trashFile` tool to move files and folders to trash.\n- **Google Drive**: Added `renameFile` tool to rename files and folders.\n- **Gmail**: Added `batchModify` tool for bulk modifying up to 1,000 messages at\n  once.\n- **Gmail**: Added `modifyThread` tool for modifying all messages in a Gmail\n  thread.\n- **Gmail**: Added `threadId` support in `createDraft` for creating reply\n  drafts.\n- **Authentication**: Added headless OAuth login for SSH, WSL, and Cloud Shell\n  environments.\n\n### Skills\n\n- **Gmail Skill**: Added rich HTML formatting guidance for email composition.\n- **Chat Skill**: Added Google Chat messaging and space management guidance.\n- **Docs Skill**: Added document formatting and simplified tool primitives.\n- **Calendar Skill**: Added consolidated calendar scheduling guidance.\n\n### Fixes\n\n- **Docs**: Fixed recursion into nested child tabs in DocsService.\n- **Docs**: Polished `getSuggestions` and `getComments` output formatting.\n- **Drive**: Fixed shared drive file downloads.\n\n### Documentation & Chores\n\n- Documented token storage locations (OS keychain and encrypted file fallback).\n- Updated tool reference documentation with latest features.\n- **Dependencies**: Updated MCP SDK, Hono, Google APIs, rollup, ajv, qs, and\n  minimatch.\n\n## 0.0.5 (2026-02-11)\n\n### New Features\n\n- **Gmail**: Added `createLabel` tool to manage email labels.\n- **Slides**: Added `getImages` and `getSlideThumbnail` tools for better visual\n  integration, and included slide IDs in `getMetadata` output.\n- **Drive**: Enhanced support for shared drives.\n- **Calendar**: Added support for event descriptions.\n- **GCP**: Added comprehensive documentation and automation for GCP project\n  recreation.\n- **Logging**: Added authentication status updates via MCP logging for better\n  observability.\n- **Tools**: Added annotations for read-only tools to improve agent interaction.\n\n### Fixes\n\n- **Security**: Resolved esbuild vulnerability via vite override.\n- **Compatibility**: Normalised tool names to underscores for better\n  compatibility with other agents (e.g., Cursor).\n- **Config**: Removed unused arguments in extension configuration.\n\n### Documentation & Chores\n\n- **Formatting**: Updated context documentation with Chat-specific formatting\n  instructions.\n- **Infrastructure**: Allowed `.gemini` directory in git and added Prettier to\n  CI/CD pipeline.\n- **Dependencies**: Updated MCP SDK, Hono, Google APIs, and other core\n  libraries.\n\n## 0.0.4 (2026-01-05)\n\n### New Features\n\n- **Google Drive**: Added `drive.createFolder` to create new folders.\n- **People**: Added `people.getUserRelations` to retrieve user relationships\n  (manager, reports, etc.).\n- **Google Chat**: Added threading support to `chat.sendMessage` and\n  `chat.sendDm`, and filtering by thread in `chat.getMessages`.\n- **Gmail**: Added `gmail.downloadAttachment` to download email attachments.\n- **Google Drive**: Added `drive.downloadFile` to download files from Google\n  Drive.\n- **Calendar**: Added `calendar.deleteEvent` to delete calendar events.\n- **Google Docs**: Added support for Tabs in DocsService.\n\n### Improvements\n\n- **Dependencies**: Updated various dependencies including `@googleapis/drive`,\n  `google-googleapis`, and `jsdom`.\n- **CI/CD**: Added a weekly preview release workflow and updated GitHub Actions\n  versions.\n- **Testing**: Added documentation for the testing process with Gemini CLI.\n\n### Fixes\n\n- Fixed an issue where the `v` prefix was not stripped correctly in the release\n  script.\n- Fixed an issue with invalid assignees in dependabot config.\n- Fixed log directory creation.\n\n## 0.0.3\n\n- Initial release with support for Google Docs, Sheets, Slides, Drive, Calendar,\n  Gmail, Chat, Time, and People.\n"
  },
  {
    "path": "eslint.config.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst tseslint = require('@typescript-eslint/eslint-plugin');\nconst tsParser = require('@typescript-eslint/parser');\nconst headers = require('eslint-plugin-headers');\nconst importPlugin = require('eslint-plugin-import');\n\nmodule.exports = [\n  {\n    ignores: [\n      '**/dist/',\n      '*.js',\n      '**/node_modules/',\n      '**/coverage/',\n      '!eslint.config.js',\n      '**/docs/.vitepress/cache/',\n      '**/docs/.vitepress/dist/',\n    ],\n  },\n  {\n    files: ['workspace-server/src/**/*.ts'],\n    ignores: ['**/*.test.ts', '**/*.spec.ts'],\n    languageOptions: {\n      parser: tsParser,\n      parserOptions: {\n        project: true,\n        tsconfigRootDir: __dirname,\n        ecmaVersion: 2020,\n        sourceType: 'module',\n      },\n    },\n    plugins: {\n      '@typescript-eslint': tseslint,\n    },\n    rules: {\n      ...tseslint.configs.recommended.rules,\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/explicit-function-return-type': 'off',\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n          caughtErrorsIgnorePattern: '^_',\n        },\n      ],\n      'prefer-const': 'warn',\n    },\n  },\n  {\n    files: [\n      'workspace-server/src/**/*.test.ts',\n      'workspace-server/src/**/*.spec.ts',\n    ],\n    languageOptions: {\n      parser: tsParser,\n      parserOptions: {\n        ecmaVersion: 2020,\n        sourceType: 'module',\n      },\n    },\n    plugins: {\n      '@typescript-eslint': tseslint,\n    },\n    rules: {\n      ...tseslint.configs.recommended.rules,\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/explicit-function-return-type': 'off',\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_',\n        },\n      ],\n      'prefer-const': 'warn',\n    },\n  },\n  {\n    files: ['./**/*.{tsx,ts,js}'],\n    ignores: ['workspace-server/src/index.ts'], // Has shebang which conflicts with license header\n    plugins: {\n      headers,\n      import: importPlugin,\n    },\n    rules: {\n      'headers/header-format': [\n        'error',\n        {\n          source: 'string',\n          content: [\n            '@license',\n            'Copyright (year) Google LLC',\n            'SPDX-License-Identifier: Apache-2.0',\n          ].join('\\n'),\n          patterns: {\n            year: {\n              pattern: '202[5-6]',\n              defaultValue: '2026',\n            },\n          },\n        },\n      ],\n      'import/enforce-node-protocol-usage': ['error', 'always'],\n    },\n  },\n  {\n    files: ['workspace-server/src/index.ts'],\n    plugins: {\n      import: importPlugin,\n    },\n    rules: {\n      'import/enforce-node-protocol-usage': ['error', 'always'],\n    },\n  },\n];\n"
  },
  {
    "path": "gemini-extension.json",
    "content": "{\n  \"name\": \"google-workspace\",\n  \"version\": \"0.0.8\",\n  \"contextFileName\": \"workspace-server${/}WORKSPACE-Context.md\",\n  \"mcpServers\": {\n    \"google-workspace\": {\n      \"command\": \"node\",\n      \"args\": [\"scripts${/}start.js\"],\n      \"cwd\": \"${extensionPath}\"\n    }\n  }\n}\n"
  },
  {
    "path": "jest.config.js",
    "content": "/** @type {import('jest').Config} */\nmodule.exports = {\n  preset: 'ts-jest',\n  testEnvironment: 'node',\n  projects: [\n    {\n      displayName: 'workspace-server',\n      testMatch: [\n        '<rootDir>/workspace-server/src/**/*.test.ts',\n        '<rootDir>/workspace-server/src/**/*.spec.ts',\n      ],\n      transform: {\n        '^.+\\\\.ts$': [\n          'ts-jest',\n          {\n            tsconfig: {\n              strict: false,\n              types: ['jest', 'node'],\n            },\n          },\n        ],\n      },\n      transformIgnorePatterns: ['node_modules/'],\n      moduleNameMapper: {\n        '^@/(.*)$': '<rootDir>/workspace-server/src/$1',\n        '\\\\.wasm$': '<rootDir>/workspace-server/src/__tests__/mocks/wasm.js',\n      },\n      roots: ['<rootDir>/workspace-server/src'],\n      setupFilesAfterEnv: ['<rootDir>/workspace-server/src/__tests__/setup.ts'],\n      collectCoverageFrom: [\n        '<rootDir>/workspace-server/src/**/*.ts',\n        '!<rootDir>/workspace-server/src/**/*.d.ts',\n        '!<rootDir>/workspace-server/src/**/*.test.ts',\n        '!<rootDir>/workspace-server/src/**/*.spec.ts',\n        '!<rootDir>/workspace-server/src/index.ts',\n      ],\n      coverageDirectory: '<rootDir>/coverage',\n      coverageThreshold: {\n        global: {\n          branches: 45,\n          functions: 65,\n          lines: 60,\n          statements: 60,\n        },\n      },\n    },\n  ],\n  coverageReporters: ['text', 'lcov', 'html'],\n  testTimeout: 10000,\n  verbose: true,\n};\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"gemini-workspace-extension\",\n  \"version\": \"0.0.8\",\n  \"description\": \"Google Workspace Server Extension\",\n  \"private\": true,\n  \"bin\": {\n    \"gemini-workspace-server\": \"workspace-server/dist/index.js\"\n  },\n  \"workspaces\": [\n    \"workspace-server\"\n  ],\n  \"scripts\": {\n    \"prepare\": \"npm run build\",\n    \"build\": \"npm run build --workspaces --if-present\",\n    \"test\": \"npm run test --workspaces --if-present\",\n    \"test:watch\": \"npm run test:watch --workspaces --if-present\",\n    \"test:coverage\": \"npm run test:coverage --workspaces --if-present\",\n    \"test:ci\": \"npm run test:ci --workspaces --if-present\",\n    \"start\": \"npm run start --workspaces --if-present\",\n    \"auth-utils\": \"npm run build:auth-utils -w workspace-server && node scripts/auth-utils.js\",\n    \"clean\": \"node scripts/clean.js\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint . --fix\",\n    \"format:check\": \"prettier --check .\",\n    \"format:fix\": \"prettier --write .\",\n    \"release\": \"node scripts/release.js\",\n    \"release:dev\": \"npm install && npm run build && node scripts/release.js\",\n    \"set-version\": \"node scripts/set-version.js\",\n    \"version\": \"node scripts/set-version.js && git add workspace-server/package.json\",\n    \"docs:dev\": \"vitepress dev docs\",\n    \"docs:build\": \"vitepress build docs\",\n    \"docs:preview\": \"vitepress preview docs\"\n  },\n  \"dependencies\": {\n    \"@google-apps/chat\": \"^0.23.0\",\n    \"@google-cloud/local-auth\": \"^3.0.1\",\n    \"@googleapis/docs\": \"^9.2.1\",\n    \"@googleapis/drive\": \"^20.1.0\",\n    \"@modelcontextprotocol/sdk\": \"^1.29.0\",\n    \"google-auth-library\": \"^10.6.2\",\n    \"googleapis\": \"^171.2.0\",\n    \"keytar\": \"^7.9.0\"\n  },\n  \"devDependencies\": {\n    \"@jest/globals\": \"^30.3.0\",\n    \"@types/jest\": \"^30.0.0\",\n    \"@types/node\": \"^25.6.0\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.59.1\",\n    \"@typescript-eslint/parser\": \"^8.55.0\",\n    \"@vercel/ncc\": \"^0.38.3\",\n    \"archiver\": \"^7.0.1\",\n    \"esbuild\": \"^0.28.0\",\n    \"eslint\": \"^9.39.4\",\n    \"eslint-plugin-headers\": \"^1.3.4\",\n    \"eslint-plugin-import\": \"^2.32.0\",\n    \"eslint-plugin-license-header\": \"^0.9.0\",\n    \"jest\": \"^30.3.0\",\n    \"minimist\": \"^1.2.8\",\n    \"prettier\": \"^3.8.3\",\n    \"ts-jest\": \"^29.4.9\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"^6.0.3\",\n    \"vite\": \"^8.0.10\",\n    \"vitepress\": \"^1.6.4\",\n    \"vue\": \"^3.5.33\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/gemini-cli-extensions/workspace.git\"\n  },\n  \"keywords\": [\n    \"google-workspace\",\n    \"gmail\",\n    \"google-docs\",\n    \"google-drive\",\n    \"google-calendar\",\n    \"google-chat\",\n    \"google-people\",\n    \"google-sheets\",\n    \"google-slides\",\n    \"gemini-cli\"\n  ],\n  \"author\": \"Allen Hutchison\",\n  \"license\": \"Apache-2.0\",\n  \"overrides\": {\n    \"vite\": \"$vite\"\n  }\n}\n"
  },
  {
    "path": "scripts/auth-utils.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst {\n  OAuthCredentialStorage,\n} = require('../workspace-server/dist/auth-utils.js');\n\nasync function clearAuth() {\n  try {\n    await OAuthCredentialStorage.clearCredentials();\n    console.log('✅ Authentication credentials cleared successfully.');\n  } catch (error) {\n    console.error('❌ Failed to clear authentication credentials:', error);\n    process.exit(1);\n  }\n}\n\nasync function expireToken() {\n  try {\n    const credentials = await OAuthCredentialStorage.loadCredentials();\n    if (!credentials) {\n      console.log('ℹ️  No credentials found to expire.');\n      return;\n    }\n\n    // Set expiry to 1 second ago\n    credentials.expiry_date = Date.now() - 1000;\n    await OAuthCredentialStorage.saveCredentials(credentials);\n    console.log('✅ Access token expired successfully.');\n    console.log('   Next API call will trigger proactive refresh.');\n  } catch (error) {\n    console.error('❌ Failed to expire token:', error);\n    process.exit(1);\n  }\n}\n\nasync function showStatus() {\n  try {\n    const credentials = await OAuthCredentialStorage.loadCredentials();\n    if (!credentials) {\n      console.log('ℹ️  No credentials found.');\n      return;\n    }\n\n    const now = Date.now();\n    const expiry = credentials.expiry_date;\n    const hasRefreshToken = !!credentials.refresh_token;\n    const hasAccessToken = !!credentials.access_token;\n    const isExpired = expiry ? expiry < now : false;\n\n    console.log('📊 Auth Status:');\n    console.log(\n      `   Access Token: ${hasAccessToken ? '✅ Present' : '❌ Missing'}`,\n    );\n    console.log(\n      `   Refresh Token: ${hasRefreshToken ? '✅ Present' : '❌ Missing'}`,\n    );\n    if (expiry) {\n      console.log(`   Expiry: ${new Date(expiry).toISOString()}`);\n      console.log(`   Status: ${isExpired ? '❌ EXPIRED' : '✅ Valid'}`);\n      if (!isExpired) {\n        const minutesLeft = Math.floor((expiry - now) / 1000 / 60);\n        console.log(`   Time left: ~${minutesLeft} minutes`);\n      }\n    } else {\n      console.log(`   Expiry: ⚠️  Unknown`);\n    }\n  } catch (error) {\n    console.error('❌ Failed to get auth status:', error);\n    process.exit(1);\n  }\n}\n\nasync function login() {\n  try {\n    require('../workspace-server/dist/headless-login.js');\n  } catch (error) {\n    console.error(\n      '❌ Failed to load headless-login module. Run \"npm run build:headless-login\" first.',\n    );\n    console.error(error.message);\n    process.exit(1);\n  }\n}\n\nfunction showHelp() {\n  console.log(`\nAuth Management CLI\n\nUsage: npm run auth-utils -- <command>\n\nCommands:\n  login     Authenticate via headless OAuth flow (for SSH/WSL/Cloud Shell)\n  clear     Clear all authentication credentials\n  expire    Force the access token to expire (for testing refresh)\n  status    Show current authentication status\n  help      Show this help message\n\nExamples:\n  npm run auth-utils -- login\n  npm run auth-utils -- clear\n  npm run auth-utils -- expire\n  npm run auth-utils -- status\n`);\n}\n\nasync function main() {\n  const command = process.argv[2];\n\n  switch (command) {\n    case 'login':\n      await login();\n      break;\n    case 'clear':\n      await clearAuth();\n      break;\n    case 'expire':\n      await expireToken();\n      break;\n    case 'status':\n      await showStatus();\n      break;\n    case 'help':\n    case '--help':\n    case '-h':\n      showHelp();\n      break;\n    default:\n      if (!command) {\n        console.error('❌ No command specified.');\n      } else {\n        console.error(`❌ Unknown command: ${command}`);\n      }\n      showHelp();\n      process.exit(1);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "scripts/clean.js",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst { rmSync, readFileSync } = require('node:fs');\nconst { join } = require('node:path');\n\nconst root = join(__dirname, '..');\nconst RMRF = { recursive: true, force: true };\n\nfunction rmrfSyncVerbose(path) {\n  console.log(`Removing ${path}`);\n  rmSync(path, RMRF);\n}\n\n// Clean up all workspaces.\nconst { workspaces } = JSON.parse(\n  readFileSync(join(root, 'package.json'), 'utf-8'),\n);\nfor (const workspace of workspaces) {\n  rmrfSyncVerbose(join(root, workspace, 'dist'));\n}\n\n// Root artifacts.\nrmrfSyncVerbose(join(root, 'node_modules'));\nrmrfSyncVerbose(join(root, 'release'));\nrmrfSyncVerbose(join(root, 'logs'));\nrmrfSyncVerbose(join(root, 'docs', '.vitepress', 'cache'));\nrmrfSyncVerbose(join(root, 'docs', '.vitepress', 'dist'));\n"
  },
  {
    "path": "scripts/list-deps.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst path = require('node:path');\nconst { getTransitiveDependencies } = require('./utils/dependencies');\n\nconst root = path.join(__dirname, '..');\nconst targetPackages = process.argv.slice(2);\n\nif (targetPackages.length === 0) {\n  console.log('Usage: node scripts/list-deps.js <package1> [package2...]');\n  process.exit(1);\n}\n\nconsole.log(`Analyzing dependencies for: ${targetPackages.join(', ')}`);\n\nconst allDeps = getTransitiveDependencies(root, targetPackages);\n\nconsole.log('\\nTransitive Dependencies:');\nArray.from(allDeps)\n  .sort()\n  .forEach((dep) => {\n    console.log(`- ${dep}`);\n  });\n"
  },
  {
    "path": "scripts/print-scopes.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Prints the OAuth scopes that should be registered on the GCP consent\n * screen, one per line. Sourced from FEATURE_GROUPS so the registration\n * list and the runtime request list cannot drift.\n *\n * Used by scripts/setup-gcp.sh.\n */\n\nimport { getAllPossibleScopes } from '../workspace-server/src/features/feature-config';\n\nfor (const scope of getAllPossibleScopes()) {\n  console.log(scope);\n}\n"
  },
  {
    "path": "scripts/release.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst archiver = require('archiver');\nconst argv = require('minimist')(process.argv.slice(2));\n\nconst deleteFilesByExtension = (dir, ext) => {\n  if (!fs.existsSync(dir)) {\n    return;\n  }\n\n  const files = fs.readdirSync(dir);\n  for (const file of files) {\n    const filePath = path.join(dir, file);\n    const stat = fs.lstatSync(filePath);\n    if (stat.isDirectory()) {\n      deleteFilesByExtension(filePath, ext);\n    } else if (filePath.endsWith(ext)) {\n      fs.unlinkSync(filePath);\n    }\n  }\n};\n\nconst main = async () => {\n  const platform = argv.platform;\n  if (platform && typeof platform !== 'string') {\n    console.error(\n      'Error: The --platform argument must be a string (e.g., --platform=linux).',\n    );\n    process.exit(1);\n  }\n  const baseName = 'google-workspace-extension';\n  const name = platform ? `${platform}.${baseName}` : baseName;\n  const extension = 'tar.gz';\n\n  const rootDir = path.join(__dirname, '..');\n  const releaseDir = path.join(rootDir, 'release');\n  fs.rmSync(releaseDir, { recursive: true, force: true });\n  const archiveName = `${name}.${extension}`;\n  const archiveDir = path.join(releaseDir, name);\n  const workspaceMcpServerDir = path.join(rootDir, 'workspace-server');\n\n  // Create the release directory\n  fs.mkdirSync(releaseDir, { recursive: true });\n\n  // Create the platform-specific directory\n  fs.mkdirSync(archiveDir, { recursive: true });\n\n  // Copy the dist directory\n  fs.cpSync(\n    path.join(workspaceMcpServerDir, 'dist'),\n    path.join(archiveDir, 'dist'),\n    { recursive: true },\n  );\n\n  // Clean up the dist directory\n  const distDir = path.join(archiveDir, 'dist');\n  deleteFilesByExtension(distDir, '.d.ts');\n  deleteFilesByExtension(distDir, '.map');\n  fs.rmSync(path.join(distDir, '__tests__'), { recursive: true, force: true });\n  fs.rmSync(path.join(distDir, 'auth'), { recursive: true, force: true });\n  fs.rmSync(path.join(distDir, 'services'), { recursive: true, force: true });\n  fs.rmSync(path.join(distDir, 'utils'), { recursive: true, force: true });\n\n  // Copy native modules and dependencies (keytar, jsdom)\n  const nodeModulesDir = path.join(archiveDir, 'node_modules');\n  fs.mkdirSync(nodeModulesDir, { recursive: true });\n\n  const { getTransitiveDependencies } = require('./utils/dependencies');\n  const visited = getTransitiveDependencies(rootDir, ['keytar', 'jsdom']);\n\n  visited.forEach((pkg) => {\n    const source = path.join(rootDir, 'node_modules', pkg);\n    const dest = path.join(nodeModulesDir, pkg);\n    if (fs.existsSync(source)) {\n      fs.cpSync(source, dest, { recursive: true });\n    }\n  });\n\n  const packageJson = require('../package.json');\n  const version = (process.env.GITHUB_REF_NAME || packageJson.version).replace(\n    /^v/,\n    '',\n  );\n\n  // Generate the gemini-extension.json file\n  const geminiExtensionJson = {\n    name: 'google-workspace',\n    version,\n    contextFileName: 'WORKSPACE-Context.md',\n    mcpServers: {\n      'google-workspace': {\n        command: 'node',\n        args: ['dist/index.js', '--use-dot-names'],\n        cwd: '${extensionPath}',\n      },\n    },\n  };\n  fs.writeFileSync(\n    path.join(archiveDir, 'gemini-extension.json'),\n    JSON.stringify(geminiExtensionJson, null, 2),\n  );\n\n  // Copy the WORKSPACE-Context.md file\n  fs.copyFileSync(\n    path.join(workspaceMcpServerDir, 'WORKSPACE-Context.md'),\n    path.join(archiveDir, 'WORKSPACE-Context.md'),\n  );\n\n  // Copy the commands directory\n  const commandsDir = path.join(rootDir, 'commands');\n  if (fs.existsSync(commandsDir)) {\n    fs.cpSync(commandsDir, path.join(archiveDir, 'commands'), {\n      recursive: true,\n    });\n  }\n\n  // Copy the skills directory\n  const skillsDir = path.join(rootDir, 'skills');\n  if (fs.existsSync(skillsDir)) {\n    fs.cpSync(skillsDir, path.join(archiveDir, 'skills'), {\n      recursive: true,\n    });\n  }\n\n  // Create the archive\n  const output = fs.createWriteStream(path.join(releaseDir, archiveName));\n  const archive = archiver('tar', {\n    gzip: true,\n  });\n\n  const archivePromise = new Promise((resolve, reject) => {\n    output.on('close', function () {\n      console.log(archive.pointer() + ' total bytes');\n      console.log(\n        'archiver has been finalized and the output file descriptor has closed.',\n      );\n      resolve();\n    });\n\n    archive.on('error', function (err) {\n      reject(err);\n    });\n  });\n\n  archive.pipe(output);\n  archive.directory(archiveDir, false);\n  archive.finalize();\n\n  await archivePromise;\n};\n\nmain().catch((err) => {\n  console.error(err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "scripts/set-version.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst rootDir = path.join(__dirname, '..');\nconst packageJsonPath = path.join(rootDir, 'package.json');\nconst workspaceServerPackageJsonPath = path.join(\n  rootDir,\n  'workspace-server',\n  'package.json',\n);\nconst geminiExtensionJsonPath = path.join(rootDir, 'gemini-extension.json');\nconst workspaceServerIndexPath = path.join(\n  rootDir,\n  'workspace-server',\n  'src',\n  'index.ts',\n);\n\nconst updateJsonFile = (filePath, version) => {\n  try {\n    const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n    content.version = version;\n    fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\\n');\n    console.log(\n      `Updated ${path.relative(rootDir, filePath)} to version ${version}`,\n    );\n  } catch (error) {\n    console.error(\n      `Failed to update JSON file at ${path.relative(rootDir, filePath)}:`,\n      error,\n    );\n    process.exit(1);\n  }\n};\n\nconst main = () => {\n  let version = process.argv[2];\n\n  if (version) {\n    // If version is provided as arg, update root package.json first\n    updateJsonFile(packageJsonPath, version);\n  } else {\n    // Otherwise read from root package.json\n    const packageJson = require(packageJsonPath);\n    version = packageJson.version;\n    console.log(`Using version from package.json: ${version}`);\n  }\n\n  if (!version) {\n    console.error('No version specified and no version found in package.json');\n    process.exit(1);\n  }\n\n  updateJsonFile(workspaceServerPackageJsonPath, version);\n  updateJsonFile(geminiExtensionJsonPath, version);\n};\n\nmain();\n"
  },
  {
    "path": "scripts/setup-gcp.sh",
    "content": "#!/bin/bash\n\n# GCP Setup Script for Google Workspace Extension\n# This script is idempotent — it can be safely re-run without breaking existing config.\n\nset -e\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\n# Helper to open a URL in the default browser\nopen_url() {\n    if command -v open &> /dev/null; then\n        open \"$1\"\n    elif command -v xdg-open &> /dev/null; then\n        xdg-open \"$1\"\n    else\n        echo -e \"Could not open browser automatically.\"\n    fi\n}\n\necho -e \"${YELLOW}Starting Google Cloud Platform setup...${NC}\"\n\n# Check if gcloud is installed\nif ! command -v gcloud &> /dev/null; then\n    echo -e \"${RED}Error: gcloud CLI is not installed. Please install it first.${NC}\"\n    exit 1\nfi\n\n# Get current project ID\nPROJECT_ID=$(gcloud config get-value project 2>/dev/null)\nif [ -z \"$PROJECT_ID\" ]; then\n    echo -e \"${RED}Error: No Google Cloud project is currently set.${NC}\"\n    echo \"Please run: gcloud config set project [PROJECT_ID]\"\n    exit 1\nfi\n\necho -e \"Using project: ${GREEN}$PROJECT_ID${NC}\"\n\nSECRET_ID=\"workspace-oauth-client-secret\"\nFUNCTION_NAME=\"workspace-oauth-handler\"\n\n# 1. Enable Required APIs\necho -e \"\\n${YELLOW}Step 1: Enabling Required APIs...${NC}\"\nAPIS=(\n    \"drive.googleapis.com\"\n    \"docs.googleapis.com\"\n    \"calendar-json.googleapis.com\"\n    \"chat.googleapis.com\"\n    \"gmail.googleapis.com\"\n    \"people.googleapis.com\"\n    \"slides.googleapis.com\"\n    \"sheets.googleapis.com\"\n    \"admin.googleapis.com\"\n    \"secretmanager.googleapis.com\"\n    \"cloudfunctions.googleapis.com\"\n    \"cloudbuild.googleapis.com\"\n    \"run.googleapis.com\"\n    \"artifactregistry.googleapis.com\"\n)\n\nfor api in \"${APIS[@]}\"; do\n    echo \"Enabling $api...\"\n    gcloud services enable \"$api\"\ndone\n\necho -e \"${GREEN}APIs enabled successfully.${NC}\"\n\n# 2. Configure OAuth Consent Screen\necho -e \"\\n${YELLOW}Step 2: Configure OAuth Consent Screen${NC}\"\necho -e \"The OAuth consent screen must be configured before creating credentials.\"\necho \"\"\n\nCONSENT_URL=\"https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID\"\n\necho -e \"Opening the OAuth consent screen configuration page...\"\nopen_url \"$CONSENT_URL\"\n\necho \"\"\necho -e \"If the page did not open, go to:\"\necho -e \"   ${GREEN}$CONSENT_URL${NC}\"\necho \"\"\necho -e \"Configure the consent screen with these settings:\"\necho -e \"  1. Select ${GREEN}Internal${NC} (Google Workspace) or ${GREEN}External${NC}\"\necho -e \"  2. Fill in the App name and Support email\"\necho -e \"  3. Add the following ${GREEN}scopes${NC} (listed below)\"\necho -e \"  4. Under ${GREEN}Test users${NC}, add the email addresses of anyone\"\necho -e \"     who will use this extension (required while in Testing mode)\"\necho \"\"\n\n# Single source of truth: scopes are computed from FEATURE_GROUPS in\n# workspace-server/src/features/feature-config.ts. See issue #323.\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nREPO_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\nSCOPES_OUTPUT=$(cd \"$REPO_ROOT\" && npx --no-install ts-node --transpile-only --project scripts/tsconfig.json scripts/print-scopes.ts 2>&1)\nif [ $? -ne 0 ]; then\n    echo -e \"${RED}Error: Failed to compute OAuth scopes from feature-config.ts.${NC}\"\n    echo -e \"${RED}Did you run 'npm install' at the repo root?${NC}\"\n    echo \"$SCOPES_OUTPUT\"\n    exit 1\nfi\n\nSCOPES=()\n# Filter to https:// lines so any Node/ts-node warnings written to stderr\n# (captured via 2>&1 above so we can surface them on failure) don't end up\n# in the user-visible scope list.\nwhile IFS= read -r line; do\n    [[ \"$line\" == https://* ]] && SCOPES+=(\"$line\")\ndone <<< \"$SCOPES_OUTPUT\"\n\nif [ ${#SCOPES[@]} -eq 0 ]; then\n    echo -e \"${RED}Error: print-scopes.ts produced no scope output.${NC}\"\n    echo \"$SCOPES_OUTPUT\"\n    exit 1\nfi\n\nfor scope in \"${SCOPES[@]}\"; do\n    echo -e \"     ${GREEN}$scope${NC}\"\ndone\n\necho \"\"\necho -e \"${YELLOW}Have you finished configuring the OAuth consent screen? (y/n)${NC}\"\nread CONSENT_DONE\nif [ \"$CONSENT_DONE\" != \"y\" ] && [ \"$CONSENT_DONE\" != \"Y\" ]; then\n    echo -e \"${RED}Please configure the OAuth consent screen before continuing.${NC}\"\n    echo -e \"Re-run this script when ready.\"\n    exit 1\nfi\n\n# 3. Deploy Cloud Function\necho -e \"\\n${YELLOW}Step 3: Deploying Cloud Function...${NC}\"\n\necho -e \"${YELLOW}Please enter the GCP region for your Cloud Function (e.g., us-central1):${NC}\"\nread REGION\nif [ -z \"$REGION\" ]; then\n    REGION=\"us-central1\"\n    echo -e \"${YELLOW}No region entered, defaulting to $REGION.${NC}\"\nfi\n\n# Check if the function already exists and get its URL\nFUNCTION_URL=\"\"\nif gcloud functions describe \"$FUNCTION_NAME\" --region=\"$REGION\" &> /dev/null; then\n    FUNCTION_URL=$(gcloud functions describe \"$FUNCTION_NAME\" --region=\"$REGION\" --format='value(serviceConfig.uri)')\n    echo -e \"${GREEN}Cloud Function already exists at: $FUNCTION_URL${NC}\"\n    echo -e \"It will be updated with the final configuration in a later step.\"\nelse\n    echo \"Deploying Cloud Function (initial)...\"\n    gcloud functions deploy \"$FUNCTION_NAME\" \\\n        --gen2 \\\n        --runtime=nodejs22 \\\n        --region=\"$REGION\" \\\n        --source=\"./cloud_function\" \\\n        --entry-point=oauthHandler \\\n        --trigger-http \\\n        --allow-unauthenticated\n\n    FUNCTION_URL=$(gcloud functions describe \"$FUNCTION_NAME\" --region=\"$REGION\" --format='value(serviceConfig.uri)')\nfi\n\nif [ -z \"$FUNCTION_URL\" ]; then\n    echo -e \"${RED}Error: Could not retrieve Cloud Function URL. Please check the deployment logs.${NC}\"\n    exit 1\nfi\n\necho -e \"${GREEN}Cloud Function URL: $FUNCTION_URL${NC}\"\n\n# 4. Collect OAuth credentials\necho -e \"\\n${YELLOW}Step 4: Configuring OAuth credentials...${NC}\"\necho -e \"Create an OAuth 2.0 Client ID in the Google Cloud Console\"\necho -e \"(or locate your existing one):\"\necho -e \"  1. Go to APIs & Services > Credentials > Create Credentials > OAuth client ID\"\necho -e \"  2. Select ${GREEN}Web application${NC}\"\necho -e \"  3. Add the following as an Authorized redirect URI:\"\necho -e \"     ${GREEN}$FUNCTION_URL${NC}\"\necho -e \"  4. Copy the Client ID and Client Secret\"\necho \"\"\n\nCREDENTIALS_URL=\"https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID\"\necho -e \"Opening the Credentials page...\"\nopen_url \"$CREDENTIALS_URL\"\n\necho \"\"\necho -e \"If the page did not open, go to:\"\necho -e \"   ${GREEN}$CREDENTIALS_URL${NC}\"\necho \"\"\n\necho -e \"${YELLOW}Please enter the OAuth 2.0 Client ID:${NC}\"\nread CLIENT_ID\nif [ -z \"$CLIENT_ID\" ]; then\n    echo -e \"${RED}Error: Client ID cannot be empty.${NC}\"\n    exit 1\nfi\n\necho -e \"${YELLOW}Please enter the OAuth 2.0 Client Secret:${NC}\"\nread -s CLIENT_SECRET\necho\nif [ -z \"$CLIENT_SECRET\" ]; then\n    echo -e \"${RED}Error: Client Secret cannot be empty.${NC}\"\n    exit 1\nfi\n\n# 5. Setup Secret Manager\necho -e \"\\n${YELLOW}Step 5: Storing Client Secret in Secret Manager...${NC}\"\n\nif gcloud secrets describe \"$SECRET_ID\" &> /dev/null; then\n    echo \"Secret $SECRET_ID already exists. Adding new version...\"\nelse\n    echo \"Creating secret $SECRET_ID...\"\n    gcloud secrets create \"$SECRET_ID\" --replication-policy=automatic\nfi\n\necho -n \"$CLIENT_SECRET\" | gcloud secrets versions add \"$SECRET_ID\" --data-file=-\necho -e \"${GREEN}Secret stored successfully.${NC}\"\n\n# 6. Update Cloud Function with OAuth configuration\necho -e \"\\n${YELLOW}Step 6: Updating Cloud Function with OAuth configuration...${NC}\"\ngcloud functions deploy \"$FUNCTION_NAME\" \\\n    --gen2 \\\n    --runtime=nodejs22 \\\n    --region=\"$REGION\" \\\n    --source=\"./cloud_function\" \\\n    --entry-point=oauthHandler \\\n    --trigger-http \\\n    --allow-unauthenticated \\\n    --set-env-vars \"CLIENT_ID=$CLIENT_ID,SECRET_NAME=projects/$PROJECT_ID/secrets/$SECRET_ID/versions/latest,REDIRECT_URI=$FUNCTION_URL\"\n\necho -e \"${GREEN}Cloud Function updated with OAuth configuration.${NC}\"\n\n# 7. Grant Permissions\necho -e \"\\n${YELLOW}Step 7: Granting Secret Manager Access to Cloud Function...${NC}\"\nSERVICE_ACCOUNT=$(gcloud functions describe \"$FUNCTION_NAME\" --region=\"$REGION\" --format='value(serviceConfig.serviceAccountEmail)')\n\ngcloud secrets add-iam-policy-binding \"$SECRET_ID\" \\\n    --member=\"serviceAccount:$SERVICE_ACCOUNT\" \\\n    --role=\"roles/secretmanager.secretAccessor\"\n\necho -e \"${GREEN}Permissions granted successfully.${NC}\"\n\necho -e \"\\n${GREEN}GCP Setup Complete!${NC}\"\necho -e \"---------------------------------------------------\"\necho -e \"${YELLOW}Next Steps:${NC}\"\necho \"Set the following environment variables in your local environment:\"\necho -e \"   ${GREEN}export WORKSPACE_CLIENT_ID=\\\"$CLIENT_ID\\\"${NC}\"\necho -e \"   ${GREEN}export WORKSPACE_CLOUD_FUNCTION_URL=\\\"$FUNCTION_URL\\\"${NC}\"\necho -e \"---------------------------------------------------\"\n"
  },
  {
    "path": "scripts/start.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst { spawn } = require('node:child_process');\nconst path = require('node:path');\n\nfunction runCommand(command, args, options) {\n  return new Promise((resolve, reject) => {\n    // On Windows, npm is a batch file and needs a shell\n    if (process.platform === 'win32' && command === 'npm') {\n      command = `${command}.cmd`;\n      options = { ...options, shell: true };\n    }\n    const child = spawn(command, args, options);\n\n    // Pipe stderr to the parent process's stderr if it's available.\n    // This is more efficient than listening for 'data' events.\n    if (child.stderr) {\n      child.stderr.pipe(process.stderr);\n    }\n\n    child.on('close', (code) => {\n      if (code !== 0) {\n        reject(\n          new Error(\n            `Command failed with code ${code}: ${command} ${args.join(' ')}`,\n          ),\n        );\n      } else {\n        resolve();\n      }\n    });\n    child.on('error', (err) => {\n      reject(err);\n    });\n  });\n}\n\nasync function main() {\n  try {\n    await runCommand('npm', ['install'], {\n      stdio: ['ignore', 'ignore', 'pipe'],\n    });\n\n    const SERVER_PATH = path.join(\n      __dirname,\n      '..',\n      'workspace-server',\n      'dist',\n      'index.js',\n    );\n    await runCommand('node', [SERVER_PATH, '--debug', '--use-dot-names'], {\n      stdio: 'inherit',\n    });\n  } catch (error) {\n    console.error(error);\n    process.exit(1);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "scripts/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"..\"\n  },\n  \"include\": [\"**/*.ts\", \"../workspace-server/src/**/*\"]\n}\n"
  },
  {
    "path": "scripts/utils/dependencies.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst fs = require('node:fs');\nconst path = require('node:path');\n\n/**\n * Gets the direct dependencies of a package from its package.json.\n * @param {string} rootDir - The root directory containing node_modules.\n * @param {string} pkgName - The name of the package.\n * @returns {string[]} - A list of dependency names.\n */\nfunction getDependencies(rootDir, pkgName) {\n  const pkgPath = path.join(rootDir, 'node_modules', pkgName, 'package.json');\n  if (!fs.existsSync(pkgPath)) {\n    return [];\n  }\n  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));\n  return Object.keys(pkg.dependencies || {});\n}\n\n/**\n * Recursively finds all transitive dependencies for a list of packages.\n * @param {string} rootDir - The root directory containing node_modules.\n * @param {string[]} startPkgs - The list of initial packages to resolve.\n * @returns {Set<string>} - A set of all transitive dependencies (including startPkgs).\n */\nfunction getTransitiveDependencies(rootDir, startPkgs) {\n  const visited = new Set();\n  const toVisit = [...startPkgs];\n\n  while (toVisit.length > 0) {\n    const pkg = toVisit.pop();\n    if (visited.has(pkg)) continue;\n    visited.add(pkg);\n\n    const deps = getDependencies(rootDir, pkg);\n    deps.forEach((dep) => {\n      if (!visited.has(dep)) {\n        toVisit.push(dep);\n      }\n    });\n  }\n\n  return visited;\n}\n\nmodule.exports = {\n  getDependencies,\n  getTransitiveDependencies,\n};\n"
  },
  {
    "path": "skills/gmail/SKILL.md",
    "content": "---\nname: gmail\ndescription: >\n  CRITICAL: You MUST activate this skill BEFORE composing, sending, drafting, or\n  searching emails. Always trigger this skill as the first step when the user\n  mentions \"email\", \"gmail\", or sending a message. Contains strict formatting\n  mandates that override default email behavior.\n---\n\n# Gmail Expert\n\nYou are an expert at composing and managing email through the Gmail API. Follow\nthese guidelines when helping users with email tasks.\n\n## Rich Text Email Formatting\n\nWhen composing emails (via `gmail.send` or `gmail.createDraft`), **always use\nHTML formatting with `isHtml: true`** unless the user explicitly requests plain\ntext. Rich HTML emails look professional and are the standard for business\ncommunication.\n\n### Supported HTML Tags\n\nGmail supports a broad set of HTML tags for email bodies. Use these freely:\n\n| Category | Tags                                                              |\n| :------- | :---------------------------------------------------------------- |\n| Text     | `<p>`, `<br>`, `<span>`, `<div>`, `<blockquote>`, `<pre>`, `<hr>` |\n| Headings | `<h1>` through `<h6>`                                             |\n| Emphasis | `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<s>`, `<strike>`        |\n| Code     | `<code>`, `<pre>`                                                 |\n| Lists    | `<ul>`, `<ol>`, `<li>`                                            |\n| Tables   | `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`           |\n| Links    | `<a href=\"...\">`                                                  |\n| Images   | `<img src=\"...\" alt=\"...\">`                                       |\n\n### Inline CSS Styling\n\nGmail strips `<style>` blocks and external stylesheets. **Always use inline\nCSS** via the `style` attribute:\n\n```html\n<!-- ✅ Correct: inline styles -->\n<p style=\"color: #333; font-family: Arial, sans-serif; font-size: 14px;\">\n  Hello!\n</p>\n\n<!-- ❌ Wrong: style block (will be stripped) -->\n<style>\n  p {\n    color: #333;\n  }\n</style>\n```\n\n### Common Inline CSS Properties\n\nThese CSS properties work reliably across Gmail clients:\n\n- **Typography**: `font-family`, `font-size`, `font-weight`, `font-style`,\n  `color`, `text-align`, `text-decoration`, `line-height`, `letter-spacing`\n- **Spacing**: `margin`, `padding` (use on `<td>` for table cell spacing)\n- **Borders**: `border`, `border-collapse` (on `<table>`)\n- **Background**: `background-color`\n- **Layout**: `width`, `max-width`, `height` (on tables and images)\n\n### Things to Avoid\n\n- ❌ `<script>` tags (blocked by all email clients)\n- ❌ `<style>` blocks (stripped by Gmail)\n- ❌ External stylesheets (`<link rel=\"stylesheet\">`)\n- ❌ `position`, `float`, `flexbox`, `grid` (unreliable in email)\n- ❌ `background-image` on non-table elements (inconsistent support)\n- ❌ JavaScript event handlers (`onclick`, etc.)\n- ❌ Form elements (`<input>`, `<select>`, `<textarea>`)\n\n### Email Template Examples\n\n#### Professional Message\n\n```html\n<div style=\"font-family: Arial, sans-serif; font-size: 14px; color: #333;\">\n  <p>Hi Team,</p>\n\n  <p>Please find the <b>Q4 results</b> summarized below:</p>\n\n  <table style=\"border-collapse: collapse; width: 100%; margin: 16px 0;\">\n    <thead>\n      <tr style=\"background-color: #f2f2f2;\">\n        <th style=\"border: 1px solid #ddd; padding: 8px; text-align: left;\">\n          Metric\n        </th>\n        <th style=\"border: 1px solid #ddd; padding: 8px; text-align: left;\">\n          Result\n        </th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr>\n        <td style=\"border: 1px solid #ddd; padding: 8px;\">Revenue</td>\n        <td style=\"border: 1px solid #ddd; padding: 8px;\">$1.2M</td>\n      </tr>\n      <tr>\n        <td style=\"border: 1px solid #ddd; padding: 8px;\">Growth</td>\n        <td style=\"border: 1px solid #ddd; padding: 8px;\">+15%</td>\n      </tr>\n    </tbody>\n  </table>\n\n  <p>Key takeaways:</p>\n  <ul>\n    <li>Revenue exceeded targets by <b>12%</b></li>\n    <li>Customer retention improved to <b>94%</b></li>\n  </ul>\n\n  <p>\n    Best regards,<br />\n    <span style=\"color: #666;\">— The Analytics Team</span>\n  </p>\n</div>\n```\n\n#### Styled Action Email\n\n```html\n<div\n  style=\"font-family: 'Helvetica Neue', Arial, sans-serif; max-width: 600px;\"\n>\n  <h2 style=\"color: #1a73e8; margin-bottom: 8px;\">Action Required</h2>\n  <p style=\"font-size: 14px; color: #555;\">\n    Your review is needed on the following document:\n  </p>\n  <p>\n    <a\n      href=\"https://docs.google.com/document/d/example\"\n      style=\"display: inline-block; background-color: #1a73e8; color: #fff;\n              padding: 10px 24px; text-decoration: none; border-radius: 4px;\n              font-size: 14px;\"\n    >\n      Open Document\n    </a>\n  </p>\n  <p style=\"font-size: 12px; color: #999;\">\n    Please respond by end of business Friday.\n  </p>\n</div>\n```\n\n## Gmail Search Syntax\n\nUse Gmail search operators with `gmail.search` for precise results:\n\n| Operator      | Example                    | Description                     |\n| :------------ | :------------------------- | :------------------------------ |\n| `from:`       | `from:alice@example.com`   | Sender                          |\n| `to:`         | `to:bob@example.com`       | Recipient                       |\n| `subject:`    | `subject:quarterly report` | Subject line                    |\n| `is:`         | `is:unread`, `is:starred`  | Message state                   |\n| `has:`        | `has:attachment`           | Has attachments                 |\n| `in:`         | `in:inbox`, `in:trash`     | Location                        |\n| `label:`      | `label:work`               | By label                        |\n| `before:`     | `before:2025/01/01`        | Before date                     |\n| `after:`      | `after:2025/01/01`         | After date                      |\n| `newer_than:` | `newer_than:7d`            | Within last N days/months/years |\n| `older_than:` | `older_than:1m`            | Older than N days/months/years  |\n| `filename:`   | `filename:report.pdf`      | Attachment filename             |\n| `size:`       | `size:5m`                  | Larger than size                |\n| `larger:`     | `larger:10M`               | Larger than size                |\n| `smaller:`    | `smaller:1M`               | Smaller than size               |\n| `OR`          | `from:alice OR from:bob`   | Either condition                |\n| `-`           | `-from:noreply@`           | Exclude                         |\n| `\"\"`          | `\"exact phrase\"`           | Exact match                     |\n\nCombine operators for precise searches:\n`from:alice@example.com has:attachment newer_than:30d subject:report`\n\n## Label Management\n\n### System Labels\n\nSystem labels use their name directly as the ID. Use `gmail.modify` to apply\nthese common operations:\n\n| Label       | Add Effect       | Remove Effect               |\n| :---------- | :--------------- | :-------------------------- |\n| `INBOX`     | Move to inbox    | Archive (remove from inbox) |\n| `UNREAD`    | Mark as unread   | Mark as read                |\n| `STARRED`   | Star the message | Unstar                      |\n| `IMPORTANT` | Mark important   | Mark not important          |\n| `SPAM`      | Mark as spam     | Remove spam classification  |\n| `TRASH`     | Move to trash    | Remove from trash           |\n\n### Custom Labels\n\nFor user-created labels, you must resolve the label ID first:\n\n1. Call `gmail.listLabels()` to get all labels with their IDs\n2. Match the desired label by name\n3. Use the label ID (e.g., `Label_42`) in `gmail.modify`\n\n## Downloading Attachments\n\n1. Use `gmail.get` with `format: 'full'` to get attachment metadata (IDs and\n   filenames)\n2. Use `gmail.downloadAttachment` with the `messageId` and `attachmentId`\n3. **Always use absolute paths** for `localPath` (e.g.,\n   `/Users/username/Downloads/file.pdf`). Relative paths will be rejected.\n\n## Threading and Replies\n\n- Use `threadId` with `gmail.createDraft` to create a reply draft linked to an\n  existing conversation\n- The service automatically fetches reply headers (`In-Reply-To`, `References`)\n  from the thread to maintain proper threading\n- Always reference previous messages when replying for context continuity\n"
  },
  {
    "path": "skills/google-calendar/SKILL.md",
    "content": "---\nname: google-calendar\ndescription: >\n  CRITICAL: You MUST activate this skill BEFORE creating, querying, or managing\n  calendar events. Always trigger this skill as the first step when the user\n  mentions \"calendar\", \"schedule\", \"meeting\", \"event\", or checking availability.\n  Contains strict behavioral mandates that override default calendar behavior.\n---\n\n# Google Calendar Expert\n\nYou are an expert at managing schedules and events through the Google Calendar\nAPI. Follow these guidelines when helping users with calendar tasks.\n\n## Timezone-First Workflow\n\n**Always establish the user's timezone before any calendar operation:**\n\n1. Call `time.getTimeZone()` (or `time.getCurrentTime()`) to get the user's\n   local timezone\n2. Use this timezone for all time displays and event creation\n3. Always include the timezone abbreviation (EST, PST, etc.) when showing times\n\n> **Important:** ISO 8601 datetimes sent to the API must include a timezone\n> offset (e.g., `2025-01-15T10:30:00-05:00`) or use UTC (`Z`). Never send \"bare\"\n> datetimes without an offset.\n\n## Always Pass `calendarId`\n\n**You MUST pass `calendarId: \"primary\"` on every calendar tool call that accepts\nit.** Do not omit this parameter — while the API may default to the primary\ncalendar, omitting it wastes an execution turn when the call fails or requires\nclarification. Always include it explicitly:\n\n- `calendar.listEvents({ calendarId: \"primary\", ... })`\n- `calendar.createEvent({ calendarId: \"primary\", ... })`\n- `calendar.getEvent({ eventId: \"...\", calendarId: \"primary\" })`\n- `calendar.updateEvent({ eventId: \"...\", calendarId: \"primary\", ... })`\n- `calendar.deleteEvent({ eventId: \"...\", calendarId: \"primary\" })`\n- `calendar.respondToEvent({ eventId: \"...\", calendarId: \"primary\", ... })`\n\nOnly use a different `calendarId` when the user explicitly asks to work with a\nnon-primary calendar (discovered via `calendar.list`).\n\n## Understanding \"Next Meeting\"\n\nWhen asked about \"next meeting\", \"today's schedule\", or similar queries:\n\n1. **Fetch the full day's context** — Use `calendar.listEvents` with\n   `calendarId: \"primary\"`, start of day (`00:00:00`) to end of day (`23:59:59`)\n   in the user's timezone\n2. **Filter by response status** — Only show meetings where the user has:\n   - Accepted the invitation\n   - Not yet responded (needs to decide)\n   - **DO NOT** show declined meetings unless explicitly requested\n3. **Compare with current time** — Identify meetings relative to now\n4. **Handle edge cases**:\n   - If a meeting is in progress, mention it first\n   - \"Next\" means the first meeting after the current time\n   - Keep the full day context for follow-up questions\n\n## Meeting Response Filtering\n\nUse the `attendeeResponseStatus` parameter on `calendar.listEvents` to filter\nevents by the user's response:\n\n| Default Behavior      | Show Only                          |\n| :-------------------- | :--------------------------------- |\n| Standard schedule     | Accepted and pending (needsAction) |\n| \"Show all meetings\"   | Include declined                   |\n| \"What did I decline?\" | Filter to declined only            |\n\nThis respects the user's time by not cluttering their schedule with irrelevant\nmeetings.\n\n## Creating Events\n\nUse `calendar.createEvent` to add new events. **Always preview the event before\ncreating it and wait for user confirmation.**\n\n### Preview Format\n\n```\nI'll create this event:\n\n📅 Title: Weekly Standup\n📆 Date: January 15, 2025\n🕐 Time: 10:00 AM - 10:30 AM (EST)\n👥 Attendees: alice@example.com, bob@example.com\n📝 Description: Weekly team sync\n🎥 Google Meet: Will be generated\n📎 Attachments: Q1 Agenda (Google Doc)\n\nShould I create this event?\n```\n\n### Key Parameters\n\n- **`calendarId`** — **Always pass `\"primary\"`**. Use `calendar.list` to\n  discover other calendars when needed.\n- **`start` / `end`** — Two formats:\n  - **Timed events**: `{ dateTime: \"2025-01-15T10:00:00-05:00\" }` — ISO 8601\n    with timezone offset\n  - **All-day events**: `{ date: \"2025-01-15\" }` — YYYY-MM-DD format. The end\n    date is exclusive (use the next day).\n- **`attendees`** — Array of email addresses\n- **`addGoogleMeet`** — Set to `true` to automatically generate a Google Meet\n  link (available in response's `hangoutLink` field)\n- **`attachments`** — Array of Google Drive file attachments (fileUrl, title,\n  optional mimeType). Providing attachments fully replaces any existing\n  attachments.\n- **`sendUpdates`** — Controls email notifications:\n  - `\"all\"` — Notify all attendees (default when attendees are provided)\n  - `\"externalOnly\"` — Only notify non-organization attendees\n  - `\"none\"` — No notifications\n- **`eventType`** — The type of event (see\n  [Calendar Status Events](#calendar-status-events) below):\n  - `\"default\"` — Regular event (default if omitted)\n  - `\"focusTime\"` — Focus time block\n  - `\"outOfOffice\"` — Out-of-office event\n  - `\"workingLocation\"` — Working location indicator\n\n### Example — Regular Timed Event\n\n```\ncalendar.createEvent({\n  calendarId: \"primary\",\n  summary: \"Weekly Standup\",\n  start: { dateTime: \"2025-01-15T10:00:00-05:00\" },\n  end: { dateTime: \"2025-01-15T10:30:00-05:00\" },\n  attendees: [\"alice@example.com\", \"bob@example.com\"],\n  description: \"Weekly team sync\",\n  addGoogleMeet: true,\n  attachments: [{\n    fileUrl: \"https://drive.google.com/file/d/abc123/edit\",\n    title: \"Q1 Agenda\",\n    mimeType: \"application/vnd.google-apps.document\"\n  }],\n  sendUpdates: \"all\"\n})\n```\n\n### Example — All-Day Event\n\n```\ncalendar.createEvent({\n  calendarId: \"primary\",\n  summary: \"Team Offsite\",\n  start: { date: \"2025-01-15\" },\n  end: { date: \"2025-01-17\" },\n  description: \"Two-day team offsite\"\n})\n```\n\n## Calendar Status Events\n\n`calendar.createEvent` supports creating focus time, out-of-office, and working\nlocation events via the `eventType` parameter. These are all created through the\nsame tool — there are no separate tools for each type.\n\n### Focus Time\n\nBlocks concentrated work periods. Can auto-decline conflicting meetings.\n\n> **Constraint:** Focus time events **cannot be all-day events** — they must use\n> `dateTime`, not `date`.\n\n```\ncalendar.createEvent({\n  calendarId: \"primary\",\n  eventType: \"focusTime\",\n  start: { dateTime: \"2025-01-15T09:00:00-05:00\" },\n  end: { dateTime: \"2025-01-15T12:00:00-05:00\" },\n  focusTimeProperties: {\n    chatStatus: \"doNotDisturb\",\n    autoDeclineMode: \"declineOnlyNewConflictingInvitations\",\n    declineMessage: \"In focus mode, will respond later\"\n  }\n})\n```\n\n- **`summary`** defaults to `\"Focus Time\"` if omitted\n- **`focusTimeProperties.chatStatus`** — `\"doNotDisturb\"` (default) or\n  `\"available\"`\n- **`focusTimeProperties.autoDeclineMode`** —\n  `\"declineOnlyNewConflictingInvitations\"` (default),\n  `\"declineAllConflictingInvitations\"`, or `\"declineNone\"`\n- **`focusTimeProperties.declineMessage`** — optional message sent when\n  declining\n\n### Out of Office\n\nSignals unavailability and auto-declines conflicting meetings.\n\n> **Constraint:** Out-of-office events **cannot be all-day events** — they must\n> use `dateTime`, not `date`.\n\n```\ncalendar.createEvent({\n  calendarId: \"primary\",\n  eventType: \"outOfOffice\",\n  summary: \"Vacation\",\n  start: { dateTime: \"2025-01-15T00:00:00-05:00\" },\n  end: { dateTime: \"2025-01-19T00:00:00-05:00\" },\n  outOfOfficeProperties: {\n    autoDeclineMode: \"declineAllConflictingInvitations\",\n    declineMessage: \"I am on vacation until Jan 19\"\n  }\n})\n```\n\n- **`summary`** defaults to `\"Out of Office\"` if omitted\n- **`outOfOfficeProperties.autoDeclineMode`** —\n  `\"declineOnlyNewConflictingInvitations\"` (default),\n  `\"declineAllConflictingInvitations\"`, or `\"declineNone\"`\n- **`outOfOfficeProperties.declineMessage`** — optional message sent when\n  declining\n\n### Working Location\n\nIndicates where the user is working from. Supports both timed and all-day\nevents.\n\n```\ncalendar.createEvent({\n  calendarId: \"primary\",\n  eventType: \"workingLocation\",\n  start: { date: \"2025-01-15\" },\n  end: { date: \"2025-01-16\" },\n  workingLocationProperties: {\n    type: \"homeOffice\"\n  }\n})\n```\n\n- **`summary`** defaults to `\"Working Location\"` if omitted\n- **All-day working location events** must span **exactly one day**. Use the\n  next day as the exclusive `end` date.\n- **`workingLocationProperties`** is **required** when `eventType` is\n  `\"workingLocation\"`\n- **`workingLocationProperties.type`** — `\"homeOffice\"`, `\"officeLocation\"`, or\n  `\"customLocation\"`\n- **`officeLocation`** — `{ buildingId?: string, label?: string }` (when type is\n  `\"officeLocation\"`)\n- **`customLocation`** — `{ label: string }` (when type is `\"customLocation\"`)\n\n### Listing Events by Type\n\nUse the `eventTypes` parameter on `calendar.listEvents` to filter by event type:\n\n```\ncalendar.listEvents({\n  calendarId: \"primary\",\n  timeMin: \"2025-01-15T00:00:00-05:00\",\n  timeMax: \"2025-01-17T23:59:59-05:00\",\n  eventTypes: [\"focusTime\", \"outOfOffice\", \"workingLocation\"]\n})\n```\n\nAvailable types: `\"default\"`, `\"focusTime\"`, `\"outOfOffice\"`,\n`\"workingLocation\"`, `\"birthday\"`, `\"fromGmail\"`.\n\n## Updating Events\n\nUse `calendar.updateEvent` for modifications. Only the fields you provide will\nbe changed — everything else is preserved.\n\n- **Rescheduling**: Update `start` and `end`\n- **Adding attendees**: Provide the full attendee list (existing + new)\n- **Changing title/description**: Update `summary` or `description`\n- **Adding Google Meet**: Set `addGoogleMeet: true` to generate a Meet link\n- **Managing attachments**: Provide the full attachment list (replaces all\n  existing). Pass `attachments: []` to clear all attachments.\n\n> **Important:** The `attendees` field is a full replacement, not an append. To\n> add a new attendee, include all existing attendees plus the new one. The same\n> applies to `attachments` — providing attachments fully replaces any existing\n> attachments on the event.\n\n## Google Meet Integration\n\nWhen creating or updating events, you can automatically generate a Google Meet\nlink by setting `addGoogleMeet: true`:\n\n```\ncalendar.createEvent({\n  summary: \"Team Standup\",\n  start: { dateTime: \"2025-01-15T10:00:00-05:00\" },\n  end: { dateTime: \"2025-01-15T10:30:00-05:00\" },\n  addGoogleMeet: true\n})\n```\n\nThe Meet URL will be available in the response's `hangoutLink` field:\n\n```json\n{\n  \"hangoutLink\": \"https://meet.google.com/abc-defg-hij\",\n  \"conferenceData\": { ... }\n}\n```\n\n## Google Drive Attachments\n\nYou can attach Google Drive files (Docs, Sheets, Slides, PDFs, etc.) to calendar\nevents:\n\n```\ncalendar.createEvent({\n  summary: \"Budget Review\",\n  start: { dateTime: \"2025-01-16T14:00:00-05:00\" },\n  end: { dateTime: \"2025-01-16T15:00:00-05:00\" },\n  attachments: [\n    {\n      fileUrl: \"https://drive.google.com/file/d/1ABC123xyz/edit\",\n      title: \"Q1 Budget Report\",\n      mimeType: \"application/vnd.google-apps.document\"\n    }\n  ]\n})\n```\n\n**CRITICAL:** Attachments use **replacement semantics**, not append semantics.\nWhen you provide attachments, any existing attachments on the event are fully\nreplaced. To add more attachments, include all desired attachments in your\nupdate.\n\n## Deleting Events\n\nUse `calendar.deleteEvent` to remove an event. **This is a destructive action —\nalways confirm with the user before executing.**\n\n| Role      | Effect                                  |\n| :-------- | :-------------------------------------- |\n| Organizer | Cancels the event for **all** attendees |\n| Attendee  | Removes it from **your** calendar only  |\n\n## Responding to Events\n\nUse `calendar.respondToEvent` to accept, decline, or tentatively accept meeting\ninvitations:\n\n- **`responseStatus`** — `\"accepted\"`, `\"declined\"`, or `\"tentative\"`\n- **`sendNotification`** — Whether to notify the organizer (default: `true`)\n- **`responseMessage`** — Optional message to include with your response\n\n```\ncalendar.respondToEvent({\n  eventId: \"abc123\",\n  responseStatus: \"accepted\",\n  sendNotification: true,\n  responseMessage: \"Looking forward to it!\"\n})\n```\n\n## Finding Free Time\n\nUse `calendar.findFreeTime` to find available slots across multiple people's\ncalendars. This is ideal for scheduling new meetings.\n\n- **`attendees`** — Email addresses of all participants\n- **`timeMin` / `timeMax`** — The search window (ISO 8601 with timezone)\n- **`duration`** — Meeting length in minutes\n\n```\ncalendar.findFreeTime({\n  attendees: [\"alice@example.com\", \"bob@example.com\"],\n  timeMin: \"2025-01-15T09:00:00-05:00\",\n  timeMax: \"2025-01-17T17:00:00-05:00\",\n  duration: 30\n})\n```\n\n## Working with Multiple Calendars\n\nUsers may have multiple calendars (personal, work, shared team calendars).\n\n1. Use `calendar.list` to discover all available calendars\n2. Pass the appropriate `calendarId` to other tools\n3. If no `calendarId` is provided, tools default to the **primary** calendar\n\n## Tool Quick Reference\n\n| Tool                      | Action                           | Key Parameters                                                                       |\n| :------------------------ | :------------------------------- | :----------------------------------------------------------------------------------- |\n| `calendar.list`           | List all calendars               | _(none)_                                                                             |\n| `calendar.listEvents`     | List events (filterable by type) | `calendarId`, `timeMin`, `timeMax`, `eventTypes`                                     |\n| `calendar.getEvent`       | Get event details                | `eventId`, `calendarId`                                                              |\n| `calendar.createEvent`    | Create event (all types)         | `calendarId`, `summary`, `start`, `end`, `eventType`, `addGoogleMeet`, `attachments` |\n| `calendar.updateEvent`    | Modify an existing event         | `eventId`, `summary`, `start`, `end`, `attendees`, `addGoogleMeet`, `attachments`    |\n| `calendar.deleteEvent`    | Delete an event                  | `eventId`, `calendarId`                                                              |\n| `calendar.respondToEvent` | Accept/decline an invite         | `eventId`, `responseStatus`                                                          |\n| `calendar.findFreeTime`   | Find available meeting time      | `attendees`, `timeMin`, `timeMax`, `duration`                                        |\n"
  },
  {
    "path": "skills/google-chat/SKILL.md",
    "content": "---\nname: google-chat\ndescription: >\n  CRITICAL: You MUST activate this skill BEFORE sending, reading, or managing\n  Google Chat messages. Always trigger this skill as the first step when the\n  user mentions \"chat\", \"google chat\", \"message a space\", \"DM\", or sending a\n  chat message. Contains strict formatting mandates that override default\n  messaging behavior.\n---\n\n# Google Chat Expert\n\nYou are an expert at messaging and managing conversations through the Google\nChat API. Follow these guidelines when helping users with chat tasks.\n\n## Chat Message Formatting\n\nWhen composing messages (via `chat.sendMessage` or `chat.sendDm`), **always use\nGoogle Chat's supported markdown syntax**. Google Chat uses a specific subset of\nmarkdown that differs from standard markdown. You MUST convert any unsupported\nsyntax before sending.\n\n### Supported Formatting\n\n| Syntax             | Renders As        | Example                        |\n| :----------------- | :---------------- | :----------------------------- |\n| `*text*`           | **bold**          | `*Important update*`           |\n| `_text_`           | _italic_          | `_Please review_`              |\n| `~text~`           | ~~strikethrough~~ | `~no longer relevant~`         |\n| `` `code` ``       | `inline code`     | `` `git status` ``             |\n| ` ``` `            | code block        | ` ```\\ncode\\n``` `             |\n| `* ` or `- `       | bulleted list     | `* Item one\\n* Item two`       |\n| `<url\\|text>`      | hyperlink         | `<https://example.com\\|Click>` |\n| `<users/{userId}>` | @mention          | `<users/12345678>`             |\n\n### Unsupported Syntax (Convert These)\n\nAlways convert these before sending a message to Chat:\n\n| Unsupported Syntax          | Convert To                       |\n| :-------------------------- | :------------------------------- |\n| `**bold**` (double `*`)     | `*bold*` (single `*`)            |\n| `[text](url)` markdown link | `<url\\|text>` Chat link format   |\n| `# Heading`                 | `*Heading*` (bold text)          |\n| Nested lists                | Flatten to a single-level list.  |\n| `> blockquote`              | Preserve the `>` character as-is |\n\n### Message Formatting Examples\n\n#### Status Update\n\n```\n*Project Status Update*\n\n_Sprint 14 Summary:_\n\n* Completed 12 of 15 story points\n* ~Deferred analytics dashboard~ (moved to Sprint 15)\n* Key PR: <https://github.com/org/repo/pull/42|#42 - Auth refactor>\n\nNext steps: `deploy-staging` pipeline runs tonight.\n```\n\n#### Code Snippet\n\n````\nFound the bug. The issue is in the handler:\n\n```\nfunc handleRequest(ctx context.Context) error {\n    // Missing nil check here\n    if ctx == nil {\n        return ErrNilContext\n    }\n    return process(ctx)\n}\n```\n\n<users/12345678> can you review this fix?\n````\n\n## Spaces vs. Direct Messages\n\nGoogle Chat has two main messaging contexts. Use the right tool for each:\n\n### Spaces (Group Conversations)\n\nSpaces are shared group conversations with a display name.\n\n| Action             | Tool                   | Key Parameter              |\n| :----------------- | :--------------------- | :------------------------- |\n| Find a space       | `chat.findSpaceByName` | `displayName`              |\n| List all spaces    | `chat.listSpaces`      | _(none)_                   |\n| Send a message     | `chat.sendMessage`     | `spaceName`, `message`     |\n| Create a new space | `chat.setUpSpace`      | `displayName`, `userNames` |\n\n### Direct Messages (1:1)\n\nDMs are private conversations between two users, identified by email.\n\n| Action          | Tool                 | Key Parameter      |\n| :-------------- | :------------------- | :----------------- |\n| Find a DM space | `chat.findDmByEmail` | `email`            |\n| Send a DM       | `chat.sendDm`        | `email`, `message` |\n\n> **Note:** `chat.sendDm` and `chat.findDmByEmail` both use `spaces.setup` under\n> the hood. If no DM space exists with the user, one is automatically created.\n> There is no need to create a DM space separately.\n\n> **Limitation:** DM tools only support 1:1 conversations. For group\n> conversations (3+ people), use `chat.setUpSpace` to create a named space\n> instead.\n\n## Threading\n\nThreads keep related messages grouped together within a space. Use the\n`threadName` parameter to reply in an existing thread.\n\n### How Threading Works\n\n1. **Start a new thread**: Send a message without `threadName`. The response\n   will include a `thread.name` you can use for replies.\n2. **Reply to a thread**: Pass `threadName` when calling `chat.sendMessage` or\n   `chat.sendDm`. The API uses `REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` — if the\n   thread doesn't exist, a new thread is created.\n3. **List threads**: Use `chat.listThreads` to discover threads in a space. It\n   returns the most recent message of each unique thread in reverse\n   chronological order.\n4. **Get thread messages**: Use `chat.getMessages` with the `threadName`\n   parameter to get all messages in a specific thread.\n\n### Thread Example Workflow\n\n```\n1. chat.listThreads({ spaceName: \"spaces/AAAAN2J52O8\" })\n   → Returns threads with thread.name values\n\n2. chat.getMessages({\n     spaceName: \"spaces/AAAAN2J52O8\",\n     threadName: \"spaces/AAAAN2J52O8/threads/IAf4cnLqYfg\"\n   })\n   → Returns all messages in that thread\n\n3. chat.sendMessage({\n     spaceName: \"spaces/AAAAN2J52O8\",\n     message: \"Thanks for the update!\",\n     threadName: \"spaces/AAAAN2J52O8/threads/IAf4cnLqYfg\"\n   })\n   → Replies in the same thread\n```\n\n## Unread Messages\n\nUse `unreadOnly: true` on `chat.getMessages` to filter for unread messages only.\n\n### How It Works\n\nThe unread filter:\n\n1. Looks up the authenticated user's ID via the People API\n2. Finds the user's membership in the space\n3. Uses the `lastReadTime` from the membership to filter messages created after\n   that timestamp\n4. If no `lastReadTime` is found, all messages are returned (treats everything\n   as unread)\n\n### Combining Filters\n\nYou can combine `unreadOnly` with `threadName` to get unread messages in a\nspecific thread. The filters are joined with `AND`:\n\n```\nchat.getMessages({\n  spaceName: \"spaces/AAAAN2J52O8\",\n  threadName: \"spaces/AAAAN2J52O8/threads/IAf4cnLqYfg\",\n  unreadOnly: true\n})\n```\n\n## Space Management\n\n### Creating a Space\n\nUse `chat.setUpSpace` to create a new named space with members:\n\n```\nchat.setUpSpace({\n  displayName: \"Q1 Planning\",\n  userNames: [\"users/12345678\", \"users/87654321\"]\n})\n```\n\n> **Important:** The `userNames` parameter expects user resource names in the\n> format `users/{userId}`, not email addresses. Use `people.getUserProfile` to\n> look up user IDs and convert the `people/{id}` resource name to `users/{id}`.\n\n## Resource Name Formats\n\nGoogle Chat uses structured resource names. Here is a quick reference:\n\n| Resource | Format                                  | Example                                  |\n| :------- | :-------------------------------------- | :--------------------------------------- |\n| Space    | `spaces/{spaceId}`                      | `spaces/AAAAN2J52O8`                     |\n| Message  | `spaces/{spaceId}/messages/{messageId}` | `spaces/AAAAN2J52O8/messages/abc123`     |\n| Thread   | `spaces/{spaceId}/threads/{threadId}`   | `spaces/AAAAN2J52O8/threads/IAf4cnLqYfg` |\n| User     | `users/{userId}`                        | `users/12345678`                         |\n"
  },
  {
    "path": "skills/google-docs/SKILL.md",
    "content": "---\nname: google-docs\ndescription: >\n  CRITICAL: You MUST activate this skill BEFORE creating, editing, or managing\n  Google Docs. Always trigger this skill as the first step when the user\n  mentions \"document\", \"doc\", \"google doc\", or writing/editing document content.\n  Contains strict formatting mandates that override default document behavior.\n---\n\n# Google Docs Expert\n\nYou are an expert at creating and managing documents through the Google Docs\nAPI. Follow these guidelines when helping users with document tasks.\n\n## Document Formatting — Two-Step Workflow\n\nTo create richly formatted documents, use a **two-step process**:\n\n1. **Insert content** using `docs.create` or `docs.writeText` — this inserts\n   plain text\n2. **Apply formatting** using `docs.formatText` — this applies bold, italic,\n   headings, links, and other styles to specific text ranges\n\n### Calculating Indices\n\nAfter inserting text, you know the content and can calculate character\npositions. Indices are 1-based (index 1 is the start of the document body).\n\nFor example, if you insert:\n\n```\nProject Update\\n\\nStatus: On Track\\n\n```\n\nThen:\n\n- \"Project Update\" spans indices 1–15\n- \"Status: On Track\" spans indices 17–33\n\n### Supported Formatting Styles\n\n| Style           | Effect                          | API                  |\n| --------------- | ------------------------------- | -------------------- |\n| `bold`          | **Bold text**                   | updateTextStyle      |\n| `italic`        | _Italic text_                   | updateTextStyle      |\n| `underline`     | Underlined text                 | updateTextStyle      |\n| `strikethrough` | ~~Strikethrough text~~          | updateTextStyle      |\n| `code`          | `Monospace font` (Courier New)  | updateTextStyle      |\n| `link`          | Hyperlink (requires `url`)      | updateTextStyle      |\n| `heading1`      | Heading 1                       | updateParagraphStyle |\n| `heading2`      | Heading 2                       | updateParagraphStyle |\n| `heading3`      | Heading 3                       | updateParagraphStyle |\n| `heading4`      | Heading 4                       | updateParagraphStyle |\n| `heading5`      | Heading 5                       | updateParagraphStyle |\n| `heading6`      | Heading 6                       | updateParagraphStyle |\n| `normalText`    | Reset to normal paragraph style | updateParagraphStyle |\n\n### Formatting Example\n\nCreate a doc with a heading and bold text:\n\n```\n// Step 1: Create with content\ndocs.create({\n  title: \"Weekly Report\",\n  content: \"Weekly Report\\n\\nHighlights\\n\\n- Revenue up 12%\\n- 3 new launches\\n\"\n})\n\n// Step 2: Apply formatting\ndocs.formatText({\n  documentId: \"<id-from-step-1>\",\n  formats: [\n    { startIndex: 1, endIndex: 14, style: \"heading1\" },\n    { startIndex: 16, endIndex: 26, style: \"heading2\" },\n    { startIndex: 16, endIndex: 26, style: \"bold\" }\n  ]\n})\n```\n\n### Professional Document Structure\n\nWhen creating documents, use a clear heading hierarchy:\n\n- **Heading 1** — Document title (use once, at the top)\n- **Heading 2** — Major sections\n- **Heading 3** — Subsections within a section\n- **Bold** — Labels, field names, and emphasis within body text\n\n**Structure the content first, then apply formatting generously.** A\nwell-formatted document uses headings for every distinct section — not just the\ntitle. Think of each logical group of content as deserving its own heading.\n\n#### Example: PR Summary Document\n\n**Step 1 — Content:**\n\n```\nPR Summary Report\n\nPR #246: Add Gmail Skill\n\nAuthor: Allen Hutchison\nStatus: Merged\n\nThis PR introduces a new agent skill for Gmail with rich HTML formatting\nguidance, establishing the skills architecture for the extension.\n\nKey Changes:\n- Added skills/gmail/SKILL.md with email formatting instructions\n- Updated WORKSPACE-Context.md to cross-reference the new skill\n- Modified release script to bundle the skills directory\n\nPR #245: Bump rollup from 4.57.1 to 4.59.0\n\nAuthor: dependabot\nStatus: Merged\n\nRoutine dependency update for the build pipeline.\n```\n\n**Step 2 — Formatting:**\n\n```\ndocs.formatText({\n  documentId: \"<id>\",\n  formats: [\n    // Document title\n    { startIndex: 1, endIndex: 18, style: \"heading1\" },\n    // PR section headings\n    { startIndex: 20, endIndex: 45, style: \"heading2\" },\n    { startIndex: 287, endIndex: 325, style: \"heading2\" },\n    // Field labels\n    { startIndex: 47, endIndex: 54, style: \"bold\" },\n    { startIndex: 73, endIndex: 80, style: \"bold\" },\n    { startIndex: 89, endIndex: 101, style: \"bold\" },\n    { startIndex: 327, endIndex: 334, style: \"bold\" },\n    { startIndex: 347, endIndex: 354, style: \"bold\" },\n  ]\n})\n```\n\n### Formatting Best Practices\n\n1. **Always insert text first**, then apply formatting — formatting operates on\n   existing text ranges\n2. **Calculate indices carefully** — count characters including newlines (`\\n`)\n3. **Heading styles apply to the entire paragraph** — even if the range covers\n   only part of it\n4. **Multiple styles can stack** — apply both `heading2` and `bold` to the same\n   range for bold headings\n5. **Use links for URLs** — apply `link` style with a `url` field instead of\n   pasting raw URLs\n6. **Format generously** — use heading2 for every major section, bold for every\n   label or field name. A document with only a heading1 title and plain text\n   body looks unprofessional\n\n## Creating Documents\n\nUse `docs.create` to create new documents:\n\n- **Blank document**: Provide only a `title`\n- **Document with content**: Provide `title` and `content` — the content is\n  inserted into the document after creation\n- **In a specific folder**: Add `folderName` to organize the document\n\n```\ndocs.create({\n  title: \"Weekly Status Report\",\n  content: \"Status Report - Week of March 10\\n\\nHighlights\\n\\n- ...\",\n  folderName: \"Team Reports\"\n})\n```\n\n## Writing Text\n\nUse `docs.writeText` to add text to an existing document:\n\n- **Append to end** (default): `position: \"end\"` or omit position\n- **Insert at beginning**: `position: \"beginning\"`\n- **Insert at specific index**: `position: \"5\"` (numeric string)\n\n```\ndocs.writeText({\n  documentId: \"doc-id\",\n  text: \"New content\\n\",\n  position: \"end\"\n})\n```\n\n## Find and Replace\n\nUse `docs.replaceText` to find all occurrences of a string and replace them.\nThis works across all tabs by default, or in a specific tab with `tabId`.\n\n## Tab Management\n\nGoogle Docs supports multiple tabs within a single document.\n\n### Reading Tabs\n\n- **Single tab**: `docs.getText` returns plain text directly\n- **Multiple tabs**: Returns JSON array with `tabId`, `title`, `content`, and\n  `index` for each tab\n- **Specific tab**: Pass `tabId` to read only that tab\n- **Nested tabs**: Child tabs are flattened and included in results\n\n### Writing to Tabs\n\nAll write tools (`writeText`, `replaceText`, `formatText`) accept an optional\n`tabId` parameter:\n\n- **Without `tabId`**: Operates on the first tab (default)\n- **With `tabId`**: Operates on the specified tab, including nested child tabs\n\n## Document Organization\n\n### Finding Documents\n\nUse `drive.search` with a document MIME type filter to find Google Docs:\n\n```\ndrive.search({\n  query: \"mimeType='application/vnd.google-apps.document' and name contains 'Weekly Report'\"\n})\n```\n\nFor full-text search across document content, use `fullText contains` instead of\n`name contains`.\n\n### Moving Documents\n\nUse `drive.moveFile` to move a document to a different folder. You can specify\nthe destination by folder ID or folder name.\n\n## Comments & Suggestions\n\n### Reading Comments\n\nUse `drive.getComments` to retrieve all comments on a document:\n\n- Returns comment threads with author, content, timestamp, and resolution status\n- Includes **threaded replies** with author, content, timestamp, and action\n  (e.g., `resolve`, `reopen`)\n- Includes **quoted file content** showing what text the comment is anchored to\n\n```\ndrive.getComments({ fileId: \"doc-id\" })\n```\n\n### Reading Suggestions\n\nUse `docs.getSuggestions` to retrieve suggested edits from a document:\n\n- **Insertions** — text proposed for addition (`suggestedInsertionIds`)\n- **Deletions** — text proposed for removal (`suggestedDeletionIds`)\n- **Style changes** — text formatting changes (bold, italic, etc.)\n- **Paragraph style changes** — heading level changes (e.g., NORMAL_TEXT →\n  HEADING_2)\n\nEach suggestion includes the affected text, suggestion IDs, and start/end\nindices.\n\n```\ndocs.getSuggestions({ documentId: \"doc-id\" })\n```\n\n## ID Handling\n\n- All tools accept Google Drive URLs directly — no manual ID extraction needed\n- IDs and URLs are interchangeable in all `documentId` parameters\n"
  },
  {
    "path": "skills/google-sheets/SKILL.md",
    "content": "---\nname: google-sheets\ndescription: >\n  Activate this skill when the user wants to find, read, or analyze Google\n  Sheets spreadsheets. Contains guidance on searching for spreadsheets, output\n  formats, and range-based operations.\n---\n\n# Google Sheets Expert\n\nYou are an expert at working with Google Sheets spreadsheets through the\nWorkspace Extension tools. Follow these guidelines when helping users with\nspreadsheet tasks.\n\n## Finding Spreadsheets\n\nUse `drive.search` with a Sheets MIME type filter to find spreadsheets:\n\n```\ndrive.search({\n  query: \"mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Budget'\"\n})\n```\n\nFor full-text search across spreadsheet content, use `fullText contains` instead\nof `name contains`.\n\n## Reading Data\n\n### Full Spreadsheet\n\nUse `sheets.getText` to read all sheets in a spreadsheet. Choose the output\nformat based on the use case:\n\n- **text** (default): Human-readable with pipe-separated columns — good for\n  quick review\n- **csv**: Standard CSV format — good for data export and analysis\n- **json**: Structured JSON keyed by sheet name — good for programmatic\n  processing\n\n### Specific Range\n\nUse `sheets.getRange` with A1 notation to read a specific cell range:\n\n```\nsheets.getRange({\n  spreadsheetId: \"spreadsheet-id\",\n  range: \"Sheet1!A1:D10\"\n})\n```\n\n### Metadata\n\nUse `sheets.getMetadata` to get spreadsheet structure without reading data —\nincludes sheet names, row/column counts, locale, and timezone.\n\n## ID Handling\n\n- All tools accept Google Drive URLs directly — no manual ID extraction needed\n- IDs and URLs are interchangeable in all `spreadsheetId` parameters\n"
  },
  {
    "path": "skills/google-slides/SKILL.md",
    "content": "---\nname: google-slides\ndescription: >\n  Activate this skill when the user wants to find, read, or extract content from\n  Google Slides presentations. Contains guidance on searching for presentations,\n  reading text, downloading images, and getting thumbnails.\n---\n\n# Google Slides Expert\n\nYou are an expert at working with Google Slides presentations through the\nWorkspace Extension tools. Follow these guidelines when helping users with\npresentation tasks.\n\n## Finding Presentations\n\nUse `drive.search` with a Slides MIME type filter to find presentations:\n\n```\ndrive.search({\n  query: \"mimeType='application/vnd.google-apps.presentation' and name contains 'Quarterly Review'\"\n})\n```\n\nFor full-text search across presentation content, use `fullText contains`\ninstead of `name contains`.\n\n## Reading Content\n\n### Text Extraction\n\nUse `slides.getText` to extract all text content from a presentation. Text is\norganized by slide with clear separators.\n\n### Metadata\n\nUse `slides.getMetadata` to get presentation structure — includes slide count,\nobject IDs, page size, and layout information. Slide object IDs from metadata\ncan be used with `slides.getSlideThumbnail`.\n\n## Downloading Images\n\n### All Images\n\nUse `slides.getImages` to download all embedded images from a presentation to a\nlocal directory. Requires an **absolute path** for the output directory.\n\n### Slide Thumbnails\n\nUse `slides.getSlideThumbnail` to download a thumbnail of a specific slide.\nRequires the slide's `objectId` (from `slides.getMetadata` or `slides.getText`)\nand an **absolute path** for the output file.\n\n## ID Handling\n\n- All tools accept Google Drive URLs directly — no manual ID extraction needed\n- IDs and URLs are interchangeable in all `presentationId` parameters\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2020\",\n    \"module\": \"commonjs\",\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"resolveJsonModule\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true\n  },\n  \"include\": [\"workspace-server/src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"**/node_modules\",\n    \"**/dist\",\n    \"**/*.test.ts\",\n    \"**/*.spec.ts\"\n  ]\n}\n"
  },
  {
    "path": "workspace-server/.github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [18.x, 20.x]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: 'npm'\n          cache-dependency-path: workspace-mcp-server/package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n        working-directory: workspace-mcp-server\n\n      - name: Run linter\n        run: npm run lint --if-present\n        working-directory: workspace-mcp-server\n\n      - name: Run type checking\n        run: npx tsc --noEmit\n        working-directory: workspace-mcp-server\n\n      - name: Run tests\n        run: npm test\n        working-directory: workspace-mcp-server\n\n      - name: Generate coverage report\n        run: npm run test:coverage\n        working-directory: workspace-mcp-server\n\n      - name: Upload coverage to Codecov\n        uses: codecov/codecov-action@v4\n        with:\n          directory: ./workspace-mcp-server/coverage\n          flags: unittests\n          name: codecov-umbrella\n          fail_ci_if_error: false\n\n  build:\n    runs-on: ubuntu-latest\n    needs: test\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n          cache: 'npm'\n          cache-dependency-path: workspace-mcp-server/package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n        working-directory: workspace-mcp-server\n\n      - name: Build\n        run: npm run build\n        working-directory: workspace-mcp-server\n\n      - name: Upload build artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist\n          path: workspace-mcp-server/dist/\n\n  security:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Run security audit\n        run: npm audit --audit-level=moderate\n        working-directory: workspace-mcp-server\n        continue-on-error: true\n\n      - name: Check for known vulnerabilities\n        run: npx audit-ci --moderate\n        working-directory: workspace-mcp-server\n        continue-on-error: true\n"
  },
  {
    "path": "workspace-server/.github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n          cache: 'npm'\n          cache-dependency-path: workspace-mcp-server/package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n        working-directory: workspace-mcp-server\n\n      - name: Run tests\n        run: npm test\n        working-directory: workspace-mcp-server\n\n      - name: Build\n        run: npm run build\n        working-directory: workspace-mcp-server\n\n      - name: Create Release\n        id: create_release\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref }}\n          release_name: Release ${{ github.ref }}\n          draft: false\n          prerelease: false\n\n      - name: Upload Release Asset\n        uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ steps.create_release.outputs.upload_url }}\n          asset_path: ./workspace-mcp-server/dist/index.js\n          asset_name: workspace-mcp-server.js\n          asset_content_type: application/javascript\n"
  },
  {
    "path": "workspace-server/WORKSPACE-Context.md",
    "content": "# Google Workspace Extension - Behavioral Guide\n\nThis guide provides behavioral instructions for effectively using the Google\nWorkspace Extension tools. For detailed parameter documentation, refer to the\ntool descriptions in the extension itself.\n\n## 🎯 Core Principles\n\n### 1. User Context First\n\n**Always establish user context at the beginning of interactions:**\n\n- Use `people.getMe()` to understand who the user is\n- Use `time.getTimeZone()` to get the user's local timezone\n- Apply this context throughout all interactions\n- All time-based operations should respect the user's timezone\n\n### 2. Safety and Transparency\n\n**Never execute write operations without explicit confirmation:**\n\n- Preview all changes before executing\n- Show complete details in a readable format\n- Wait for clear user approval\n- Give users the opportunity to review and cancel\n\n### 3. Smart Tool Usage\n\n**Choose the right approach for each task:**\n\n- Tools automatically handle URL-to-ID conversion - don't extract IDs manually\n- Batch related operations when possible\n- Use pagination for large result sets\n- Apply appropriate formats based on the use case\n\n## 📋 Output Formatting Standards\n\n### Lists and Search Results\n\nAlways format multiple items as **numbered lists** for better readability:\n\n✅ **Correct:**\n\n```\nFound 3 documents:\n1. Budget Report 2024\n2. Q3 Sales Presentation\n3. Team Meeting Notes\n```\n\n❌ **Incorrect:**\n\n```\nFound 3 documents:\n- Budget Report 2024\n- Q3 Sales Presentation\n- Team Meeting Notes\n```\n\n### Write Operation Previews\n\nBefore any write operation, show a clear preview:\n\n```\nI'll create this calendar event:\n\nTitle: Team Standup\nDate: January 15, 2025\nTime: 10:00 AM - 10:30 AM (EST)\nAttendees: team@example.com\n\nShould I create this event?\n```\n\n## 🔄 Multi-Tool Workflows\n\n### Creating and Organizing Documents\n\nWhen creating documents in specific folders:\n\n1. Create the document with `docs.create` (blank)\n2. Move it to the target folder with `drive.moveFile`\n3. Confirm successful completion\n\nTo find Google Docs, Sheets, or Slides, use `drive.search` with a MIME type\nfilter rather than searching by name alone. Example MIME type queries:\n\n- Docs:\n  `mimeType='application/vnd.google-apps.document' and name contains 'query'`\n- Sheets:\n  `mimeType='application/vnd.google-apps.spreadsheet' and name contains 'query'`\n- Slides:\n  `mimeType='application/vnd.google-apps.presentation' and name contains 'query'`\n\n## 🚫 Common Pitfalls to Avoid\n\n### Don't Do This:\n\n- ❌ Use `extractIdFromUrl` when other tools accept URLs\n- ❌ Assume timezone without checking\n- ❌ Execute writes without preview and confirmation\n- ❌ Create files unless explicitly requested\n- ❌ Duplicate parameter documentation from tool descriptions\n- ❌ Use relative paths for file downloads (e.g., `downloads/file.txt`)\n\n### Do This Instead:\n\n- ✅ Pass URLs directly to tools that accept them\n- ✅ Get user timezone at session start\n- ✅ Preview all changes and wait for approval\n- ✅ Only create what's requested\n- ✅ Focus on behavioral guidance and best practices\n- ✅ Always use **absolute paths** for file downloads (e.g.,\n  `/Users/me/Downloads/file.txt`)\n\n## 🔍 Error Handling Patterns\n\n### Authentication Errors\n\n- If any tool returns `{\"error\":\"invalid_request\"}`, it likely indicates an\n  expired or invalid session.\n- **Action:** Call `auth.clear` to reset credentials and force a re-login.\n- Inform the user that you are resetting authentication due to an error.\n\n### Graceful Degradation\n\n- If a folder doesn't exist, offer to create it\n- If search returns no results, suggest alternatives\n- If permissions are insufficient, explain clearly\n\n### Validation Before Action\n\n- Verify file/folder existence before moving\n- Check calendar availability before scheduling\n- Validate email addresses before sending\n\n## ⚡ Performance Optimization\n\n### Batch Operations\n\n- Group related API calls when possible\n- Use field masks to request only needed data\n- Implement pagination for large datasets\n\n### Caching Strategy\n\n- Reuse user context throughout session\n- Cache frequently accessed metadata\n- Minimize redundant API calls\n\n## 📝 Session Management\n\n### Beginning of Session\n\n1. Get user profile with `people.getMe()`\n2. Get timezone with `time.getTimeZone()`\n3. Establish any relevant context\n\n### During Interaction\n\n- Maintain context awareness\n- Apply user preferences consistently\n- Handle follow-up questions efficiently\n\n### End of Session\n\n- Confirm all requested tasks completed\n- Provide summary if multiple operations performed\n- Ensure no pending confirmations\n\n## 🎨 Service-Specific Nuances\n\n### Google Docs\n\n- See the **Google Docs skill** for detailed guidance on document content\n  formatting, creation, editing, tab management, and document organization.\n\n### Google Sheets\n\n- See the **Google Sheets skill** for detailed guidance on finding spreadsheets,\n  output format selection, and range-based operations.\n\n### Google Slides\n\n- See the **Google Slides skill** for detailed guidance on finding\n  presentations, text extraction, image downloads, and slide thumbnails.\n\n### Google Calendar\n\n- See the **Google Calendar skill** for detailed guidance on timezone handling,\n  meeting queries, event management, responding to invitations, and scheduling.\n\n### Gmail\n\n- See the **Gmail skill** for detailed guidance on composing rich HTML emails,\n  search syntax, label management, attachments, and threading.\n\n### Google Chat\n\n- See the **Google Chat skill** for detailed guidance on formatting messages,\n  spaces vs. DMs, threading, unread filtering, and space management.\n\nRemember: This guide focuses on **how to think** about using these tools\neffectively. For specific parameter details, refer to the tool descriptions\nthemselves.\n"
  },
  {
    "path": "workspace-server/esbuild.auth-utils.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuild');\nconst path = require('node:path');\n\nasync function buildAuthUtils() {\n  try {\n    await esbuild.build({\n      entryPoints: ['src/auth/token-storage/oauth-credential-storage.ts'],\n      bundle: true,\n      platform: 'node',\n      target: 'node20',\n      outfile: 'dist/auth-utils.js',\n      minify: true,\n      sourcemap: true,\n      external: [\n        'keytar', // keytar is a native module and should not be bundled\n      ],\n      format: 'cjs',\n      logLevel: 'info',\n    });\n\n    console.log('Auth Utils build completed successfully!');\n  } catch (error) {\n    console.error('Auth Utils build failed:', error);\n    process.exit(1);\n  }\n}\n\nbuildAuthUtils();\n"
  },
  {
    "path": "workspace-server/esbuild.config.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuild');\nconst path = require('node:path');\nconst fs = require('node:fs');\n\nasync function build() {\n  try {\n    await esbuild.build({\n      entryPoints: ['src/index.ts'],\n      bundle: true,\n      platform: 'node',\n      target: 'node16',\n      outfile: 'dist/index.js',\n      minify: true,\n      sourcemap: true,\n      // Replace 'open' package with our wrapper\n      alias: {\n        open: path.resolve(__dirname, 'src/utils/open-wrapper.ts'),\n      },\n      // External packages that shouldn't be bundled\n      external: [],\n      // Add a loader for .node files\n      loader: {\n        '.node': 'file',\n      },\n      // Make sure CommonJS modules work properly\n      format: 'cjs',\n      logLevel: 'info',\n    });\n\n    console.log('Build completed successfully!');\n  } catch (error) {\n    console.error('Build failed:', error);\n    process.exit(1);\n  }\n}\n\nbuild();\n"
  },
  {
    "path": "workspace-server/esbuild.headless-login.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nconst esbuild = require('esbuild');\n\nasync function buildHeadlessLogin() {\n  try {\n    await esbuild.build({\n      entryPoints: ['src/cli/headless-login.ts'],\n      bundle: true,\n      platform: 'node',\n      target: 'node20',\n      outfile: 'dist/headless-login.js',\n      minify: true,\n      sourcemap: true,\n      external: [\n        'keytar', // keytar is a native module and should not be bundled\n      ],\n      format: 'cjs',\n      logLevel: 'info',\n    });\n\n    console.log('Headless Login build completed successfully!');\n  } catch (error) {\n    console.error('Headless Login build failed:', error);\n    process.exit(1);\n  }\n}\n\nbuildHeadlessLogin();\n"
  },
  {
    "path": "workspace-server/jest.config.js",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/** @type {import('jest').Config} */\nmodule.exports = {\n  // This workspace's tests are configured in the root jest.config.js\n  // as part of the 'projects' array. This file is kept for backwards\n  // compatibility and workspace-specific overrides if needed.\n};\n"
  },
  {
    "path": "workspace-server/package.json",
    "content": "{\n  \"name\": \"workspace-server\",\n  \"version\": \"0.0.8\",\n  \"description\": \"\",\n  \"main\": \"dist/index.js\",\n  \"scripts\": {\n    \"test\": \"cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --runInBand --verbose\",\n    \"test:watch\": \"cd .. && jest --watch\",\n    \"test:coverage\": \"cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --coverage\",\n    \"test:ci\": \"cd .. && node --max-old-space-size=4096 node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2\",\n    \"start\": \"ts-node src/index.ts\",\n    \"build\": \"node esbuild.config.js && node esbuild.headless-login.js\",\n    \"build:auth-utils\": \"node esbuild.auth-utils.js\",\n    \"build:headless-login\": \"node esbuild.headless-login.js\"\n  },\n  \"keywords\": [],\n  \"author\": \"Allen Hutchison\",\n  \"license\": \"Apache-2.0\",\n  \"type\": \"commonjs\",\n  \"devDependencies\": {\n    \"esbuild\": \"^0.28.0\"\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/AuthManager.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { AuthManager } from '../../auth/AuthManager';\nimport { OAuthCredentialStorage } from '../../auth/token-storage/oauth-credential-storage';\nimport { google } from 'googleapis';\n\n// Mock dependencies\njest.mock('../../auth/token-storage/oauth-credential-storage');\njest.mock('googleapis');\njest.mock('../../utils/logger');\njest.mock('../../utils/secure-browser-launcher');\n\n// Mock fetch globally for refreshToken tests\nglobal.fetch = jest.fn();\n\ndescribe('AuthManager', () => {\n  let authManager: AuthManager;\n  let mockOAuth2Client: any;\n\n  beforeEach(() => {\n    jest.clearAllMocks();\n\n    // Setup mock OAuth2 client\n    mockOAuth2Client = {\n      setCredentials: jest.fn().mockImplementation((creds) => {\n        mockOAuth2Client.credentials = creds;\n      }),\n      generateAuthUrl: jest.fn(),\n      on: jest.fn(),\n      refreshAccessToken: jest.fn(),\n      credentials: {},\n    };\n\n    (google.auth.OAuth2 as unknown as jest.Mock).mockReturnValue(\n      mockOAuth2Client,\n    );\n\n    authManager = new AuthManager(['scope1']);\n  });\n\n  it('should set up tokens event listener on client creation', async () => {\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'old_token',\n      refresh_token: 'old_refresh',\n      scope: 'scope1',\n    });\n\n    await authManager.getAuthenticatedClient();\n\n    // Verify 'on' was called for 'tokens'\n    expect(mockOAuth2Client.on).toHaveBeenCalledWith(\n      'tokens',\n      expect.any(Function),\n    );\n  });\n\n  it('should save credentials when tokens event is emitted', async () => {\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'old_token',\n      refresh_token: 'old_refresh',\n      scope: 'scope1',\n    });\n\n    await authManager.getAuthenticatedClient();\n\n    // Get the registered callback\n    const tokensCallback = mockOAuth2Client.on.mock.calls.find(\n      (call: any[]) => call[0] === 'tokens',\n    )[1];\n    expect(tokensCallback).toBeDefined();\n\n    // Simulate tokens event\n    const newTokens = {\n      access_token: 'new_token',\n      expiry_date: 123456789,\n    };\n\n    await tokensCallback(newTokens);\n\n    // Verify saveCredentials was called with merged tokens\n    // New tokens take precedence, but refresh_token is preserved from old credentials\n    expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({\n      access_token: 'new_token',\n      refresh_token: 'old_refresh', // Preserved from old credentials\n      expiry_date: 123456789,\n      // Note: scope is NOT preserved because newTokens didn't include it\n    });\n  });\n\n  it('should preserve refresh token during manual refresh if not returned', async () => {\n    // Setup initial state with a refresh token\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'old_token',\n      refresh_token: 'old_refresh_token',\n      scope: 'scope1',\n    });\n\n    // Initialize client to populate this.client\n    await authManager.getAuthenticatedClient();\n\n    // Mock fetch to simulate cloud function returning new tokens without refresh_token\n    (global.fetch as jest.Mock).mockResolvedValue({\n      ok: true,\n      json: async () => ({\n        access_token: 'new_access_token',\n        expiry_date: 999999999,\n      }),\n    });\n\n    await authManager.refreshToken();\n\n    // Verify saveCredentials was called with BOTH new access token AND old refresh token\n    expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(\n      expect.objectContaining({\n        access_token: 'new_access_token',\n        refresh_token: 'old_refresh_token',\n      }),\n    );\n  });\n\n  it('should preserve refresh token when refreshAccessToken mutates credentials in-place', async () => {\n    // Setup initial state with a refresh token\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'old_token',\n      refresh_token: 'old_refresh_token',\n      scope: 'scope1',\n    });\n\n    // Initialize client to populate this.client\n    await authManager.getAuthenticatedClient();\n\n    // Mock fetch to simulate cloud function returning new tokens without refresh_token\n    (global.fetch as jest.Mock).mockResolvedValue({\n      ok: true,\n      json: async () => ({\n        access_token: 'new_access_token',\n        expiry_date: 999999999,\n      }),\n    });\n\n    await authManager.refreshToken();\n\n    // This test verifies that the refresh_token is preserved even when\n    // the cloud function doesn't return it in the response\n    expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(\n      expect.objectContaining({\n        access_token: 'new_access_token',\n        refresh_token: 'old_refresh_token',\n      }),\n    );\n  });\n\n  it('should preserve refresh token in tokens event handler', async () => {\n    // Setup initial state with a refresh token in storage\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'old_token',\n      refresh_token: 'stored_refresh_token',\n      scope: 'scope1',\n    });\n\n    await authManager.getAuthenticatedClient();\n\n    // Get the registered callback\n    const tokensCallback = mockOAuth2Client.on.mock.calls.find(\n      (call: any[]) => call[0] === 'tokens',\n    )[1];\n\n    // Simulate automatic refresh that doesn't include refresh_token\n    const newTokens = {\n      access_token: 'auto_refreshed_token',\n      expiry_date: 999999999,\n      // Note: no refresh_token\n    };\n\n    await tokensCallback(newTokens);\n\n    // Verify saveCredentials was called with BOTH new access token AND stored refresh token\n    expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({\n      access_token: 'auto_refreshed_token',\n      expiry_date: 999999999,\n      refresh_token: 'stored_refresh_token',\n    });\n  });\n\n  it('should proactively refresh expired tokens before returning client', async () => {\n    // Setup: Load credentials with expired token\n    const expiredTime = Date.now() - 1000; // 1 second ago\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'expired_token',\n      refresh_token: 'valid_refresh',\n      expiry_date: expiredTime,\n      scope: 'scope1',\n    });\n\n    // Mock fetch to simulate cloud function returning fresh tokens\n    (global.fetch as jest.Mock).mockResolvedValue({\n      ok: true,\n      json: async () => ({\n        access_token: 'fresh_token',\n        expiry_date: Date.now() + 3600000,\n      }),\n    });\n\n    // First call: load expired credentials from storage, should trigger proactive refresh\n    const firstClient = await authManager.getAuthenticatedClient();\n    expect(firstClient).toBeDefined();\n\n    // Verify fetch was called to refresh the token\n    expect(global.fetch).toHaveBeenCalledWith(\n      'https://google-workspace-extension.geminicli.com/refreshToken',\n      expect.objectContaining({\n        method: 'POST',\n        body: expect.stringContaining('valid_refresh'),\n      }),\n    );\n\n    // Verify new token was saved with preserved refresh_token\n    expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(\n      expect.objectContaining({\n        access_token: 'fresh_token',\n        refresh_token: 'valid_refresh',\n      }),\n    );\n  });\n\n  it('should proactively refresh tokens expiring within buffer (5 minutes)', async () => {\n    // Setup: Load credentials with token expiring in 4 minutes (within 5 min buffer)\n    const TEST_EXPIRY_WITHIN_BUFFER = 4 * 60 * 1000;\n    const expiresIn4Minutes = Date.now() + TEST_EXPIRY_WITHIN_BUFFER;\n    (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({\n      access_token: 'soon_expiring_token',\n      refresh_token: 'valid_refresh',\n      expiry_date: expiresIn4Minutes,\n      scope: 'scope1',\n    });\n\n    // Mock fetch to simulate cloud function returning fresh tokens\n    (global.fetch as jest.Mock).mockResolvedValue({\n      ok: true,\n      json: async () => ({\n        access_token: 'fresh_token',\n        expiry_date: Date.now() + 60 * 60 * 1000,\n      }),\n    });\n\n    // Call getAuthenticatedClient\n    const client = await authManager.getAuthenticatedClient();\n    expect(client).toBeDefined();\n\n    // Verify fetch was called to refresh the token because it was within buffer\n    expect(global.fetch).toHaveBeenCalledWith(\n      'https://google-workspace-extension.geminicli.com/refreshToken',\n      expect.objectContaining({\n        method: 'POST',\n        body: expect.stringContaining('valid_refresh'),\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, beforeEach } from '@jest/globals';\nimport { BaseTokenStorage } from '../../../auth/token-storage/base-token-storage';\nimport type {\n  OAuthCredentials,\n  OAuthToken,\n} from '../../../auth/token-storage/types';\n\nclass TestTokenStorage extends BaseTokenStorage {\n  private storage = new Map<string, OAuthCredentials>();\n\n  async getCredentials(serverName: string): Promise<OAuthCredentials | null> {\n    return this.storage.get(serverName) || null;\n  }\n\n  async setCredentials(credentials: OAuthCredentials): Promise<void> {\n    this.validateCredentials(credentials);\n    this.storage.set(credentials.serverName, credentials);\n  }\n\n  async deleteCredentials(serverName: string): Promise<void> {\n    this.storage.delete(serverName);\n  }\n\n  async listServers(): Promise<string[]> {\n    return Array.from(this.storage.keys());\n  }\n\n  async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {\n    return new Map(this.storage);\n  }\n\n  async clearAll(): Promise<void> {\n    this.storage.clear();\n  }\n\n  override validateCredentials(credentials: OAuthCredentials): void {\n    super.validateCredentials(credentials);\n  }\n\n  override sanitizeServerName(serverName: string): string {\n    return super.sanitizeServerName(serverName);\n  }\n}\n\ndescribe('BaseTokenStorage', () => {\n  let storage: TestTokenStorage;\n\n  beforeEach(() => {\n    storage = new TestTokenStorage('gemini-cli-mcp-oauth');\n  });\n\n  describe('validateCredentials', () => {\n    it('should validate valid credentials with access token', () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      expect(() => storage.validateCredentials(credentials)).not.toThrow();\n    });\n\n    it('should validate valid credentials with refresh token', () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          refreshToken: 'refresh-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      expect(() => storage.validateCredentials(credentials)).not.toThrow();\n    });\n\n    it('should throw for missing server name', () => {\n      const credentials = {\n        serverName: '',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      } as OAuthCredentials;\n\n      expect(() => storage.validateCredentials(credentials)).toThrow(\n        'Server name is required',\n      );\n    });\n\n    it('should throw for missing token', () => {\n      const credentials = {\n        serverName: 'test-server',\n        token: null as unknown as OAuthToken,\n        updatedAt: Date.now(),\n      } as OAuthCredentials;\n\n      expect(() => storage.validateCredentials(credentials)).toThrow(\n        'Token is required',\n      );\n    });\n\n    it('should throw for missing access token and refresh token', () => {\n      const credentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: '',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      } as OAuthCredentials;\n\n      expect(() => storage.validateCredentials(credentials)).toThrow(\n        'Access token or refresh token is required',\n      );\n    });\n\n    it('should throw for missing token type', () => {\n      const credentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: '',\n        },\n        updatedAt: Date.now(),\n      } as OAuthCredentials;\n\n      expect(() => storage.validateCredentials(credentials)).toThrow(\n        'Token type is required',\n      );\n    });\n  });\n\n  describe('sanitizeServerName', () => {\n    it('should keep valid characters', () => {\n      expect(storage.sanitizeServerName('test-server.example_123')).toBe(\n        'test-server.example_123',\n      );\n    });\n\n    it('should replace invalid characters with underscore', () => {\n      expect(storage.sanitizeServerName('test@server#example')).toBe(\n        'test_server_example',\n      );\n    });\n\n    it('should handle special characters', () => {\n      expect(storage.sanitizeServerName('test server/example:123')).toBe(\n        'test_server_example_123',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  beforeEach,\n  afterEach,\n  jest,\n} from '@jest/globals';\nimport * as crypto from 'node:crypto';\nimport { promises as fs } from 'node:fs';\nimport * as path from 'node:path';\nimport { FileTokenStorage } from '../../../auth/token-storage/file-token-storage';\nimport type { OAuthCredentials } from '../../../auth/token-storage/types';\nimport {\n  ENCRYPTED_TOKEN_PATH,\n  ENCRYPTION_MASTER_KEY_PATH,\n} from '../../../utils/paths';\n\njest.mock('node:fs', () => ({\n  promises: {\n    readFile: jest.fn(),\n    writeFile: jest.fn(),\n    unlink: jest.fn(),\n    mkdir: jest.fn(),\n  },\n  existsSync: jest.fn(() => true),\n}));\n\njest.mock('node:os', () => ({\n  default: {\n    homedir: jest.fn(() => '/home/test'),\n    hostname: jest.fn(() => 'test-host'),\n    userInfo: jest.fn(() => ({ username: 'test-user' })),\n  },\n  homedir: jest.fn(() => '/home/test'),\n  hostname: jest.fn(() => 'test-host'),\n  userInfo: jest.fn(() => ({ username: 'test-user' })),\n}));\n\ndescribe('FileTokenStorage', () => {\n  let storage: FileTokenStorage;\n  const mockFs = fs as unknown as {\n    readFile: ReturnType<typeof jest.fn>;\n    writeFile: ReturnType<typeof jest.fn>;\n    unlink: ReturnType<typeof jest.fn>;\n    mkdir: ReturnType<typeof jest.fn>;\n  };\n\n  const existingCredentials: OAuthCredentials = {\n    serverName: 'existing-server',\n    token: {\n      accessToken: 'existing-token',\n      tokenType: 'Bearer',\n    },\n    updatedAt: Date.now() - 10000,\n  };\n\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  describe('when master key does not exist', () => {\n    it('should create a new master key', async () => {\n      const error = new Error('File not found');\n      (error as NodeJS.ErrnoException).code = 'ENOENT';\n      mockFs.readFile.mockRejectedValue(error);\n      storage = await FileTokenStorage.create('test-storage');\n\n      expect(mockFs.readFile).toHaveBeenCalledWith(ENCRYPTION_MASTER_KEY_PATH);\n      expect(mockFs.writeFile).toHaveBeenCalledWith(\n        ENCRYPTION_MASTER_KEY_PATH,\n        expect.any(Buffer),\n        { mode: 0o600 },\n      );\n    });\n  });\n\n  describe('when master key exists', () => {\n    it('should load the master key without creating a new one', async () => {\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n      expect(mockFs.readFile).toHaveBeenCalledWith(ENCRYPTION_MASTER_KEY_PATH);\n      expect(mockFs.writeFile).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('getCredentials', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n\n    it('should return null when file does not exist', async () => {\n      mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });\n\n      const result = await storage.getCredentials('test-server');\n      expect(result).toBeNull();\n    });\n\n    it('should return credentials even if access token is expired', async () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n          expiresAt: Date.now() - 3600000,\n        },\n        updatedAt: Date.now(),\n      };\n\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ 'test-server': credentials }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n\n      const result = await storage.getCredentials('test-server');\n      expect(result).toEqual(credentials);\n    });\n\n    it('should return credentials for valid tokens', async () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n          expiresAt: Date.now() + 3600000,\n        },\n        updatedAt: Date.now(),\n      };\n\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ 'test-server': credentials }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n\n      const result = await storage.getCredentials('test-server');\n      expect(result).toEqual(credentials);\n    });\n\n    it('should return null for corrupted files', async () => {\n      mockFs.readFile.mockResolvedValue('corrupted-data');\n\n      const result = await storage.getCredentials('test-server');\n      expect(result).toBeNull();\n    });\n  });\n\n  describe('setCredentials', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n    it('should save credentials with encryption', async () => {\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ 'existing-server': existingCredentials }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n      mockFs.mkdir.mockResolvedValue(undefined);\n      mockFs.writeFile.mockResolvedValue(undefined);\n\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      await storage.setCredentials(credentials);\n\n      expect(mockFs.mkdir).toHaveBeenCalledWith(\n        path.dirname(ENCRYPTED_TOKEN_PATH),\n        { recursive: true, mode: 0o700 },\n      );\n      expect(mockFs.writeFile).toHaveBeenCalled();\n\n      const writeCall = mockFs.writeFile.mock.calls[0];\n      expect(writeCall[0]).toBe(ENCRYPTED_TOKEN_PATH);\n      expect(writeCall[1]).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);\n      expect(writeCall[2]).toEqual({ mode: 0o600 });\n    });\n\n    it('should update existing credentials', async () => {\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ 'existing-server': existingCredentials }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n      mockFs.writeFile.mockResolvedValue(undefined);\n\n      const newCredentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'new-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      await storage.setCredentials(newCredentials);\n\n      expect(mockFs.writeFile).toHaveBeenCalled();\n      const writeCall = mockFs.writeFile.mock.calls[0];\n      const decrypted = (storage as any).decrypt(writeCall[1]);\n      const saved = JSON.parse(decrypted);\n\n      expect(saved['existing-server']).toEqual(existingCredentials);\n      expect(saved['test-server'].token.accessToken).toBe('new-token');\n    });\n  });\n\n  describe('deleteCredentials', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n    it('should throw when credentials do not exist', async () => {\n      mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });\n\n      await expect(storage.deleteCredentials('test-server')).rejects.toThrow(\n        'No credentials found for test-server',\n      );\n    });\n\n    it('should delete file when last credential is removed', async () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ 'test-server': credentials }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n      mockFs.unlink.mockResolvedValue(undefined);\n\n      await storage.deleteCredentials('test-server');\n\n      expect(mockFs.unlink).toHaveBeenCalledWith(ENCRYPTED_TOKEN_PATH);\n    });\n\n    it('should update file when other credentials remain', async () => {\n      const credentials1: OAuthCredentials = {\n        serverName: 'server1',\n        token: {\n          accessToken: 'token1',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      const credentials2: OAuthCredentials = {\n        serverName: 'server2',\n        token: {\n          accessToken: 'token2',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify({ server1: credentials1, server2: credentials2 }),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n      mockFs.writeFile.mockResolvedValue(undefined);\n\n      await storage.deleteCredentials('server1');\n\n      expect(mockFs.writeFile).toHaveBeenCalled();\n      expect(mockFs.unlink).not.toHaveBeenCalled();\n\n      const writeCall = mockFs.writeFile.mock.calls[0];\n      const decrypted = (storage as any).decrypt(writeCall[1]);\n      const saved = JSON.parse(decrypted);\n\n      expect(saved['server1']).toBeUndefined();\n      expect(saved['server2']).toEqual(credentials2);\n    });\n  });\n\n  describe('listServers', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n    it('should return empty list when file does not exist', async () => {\n      mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });\n\n      const result = await storage.listServers();\n      expect(result).toEqual([]);\n    });\n\n    it('should return list of server names', async () => {\n      const credentials: Record<string, OAuthCredentials> = {\n        server1: {\n          serverName: 'server1',\n          token: { accessToken: 'token1', tokenType: 'Bearer' },\n          updatedAt: Date.now(),\n        },\n        server2: {\n          serverName: 'server2',\n          token: { accessToken: 'token2', tokenType: 'Bearer' },\n          updatedAt: Date.now(),\n        },\n      };\n\n      const encryptedData = (storage as any).encrypt(\n        JSON.stringify(credentials),\n      );\n      mockFs.readFile.mockResolvedValue(encryptedData);\n\n      const result = await storage.listServers();\n      expect(result).toEqual(['server1', 'server2']);\n    });\n  });\n\n  describe('clearAll', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n    it('should delete the token file', async () => {\n      mockFs.unlink.mockResolvedValue(undefined);\n\n      await storage.clearAll();\n\n      expect(mockFs.unlink).toHaveBeenCalledWith(ENCRYPTED_TOKEN_PATH);\n    });\n\n    it('should not throw when file does not exist', async () => {\n      mockFs.unlink.mockRejectedValue({ code: 'ENOENT' });\n\n      await expect(storage.clearAll()).resolves.not.toThrow();\n    });\n  });\n\n  describe('encryption', () => {\n    beforeEach(async () => {\n      // All tests assume a master key exists.\n      const masterKey = crypto.randomBytes(32);\n      mockFs.readFile.mockResolvedValue(masterKey);\n      storage = await FileTokenStorage.create('test-storage');\n    });\n    it('should encrypt and decrypt data correctly', () => {\n      const original = 'test-data-123';\n      const encrypted = (storage as any).encrypt(original);\n      const decrypted = (storage as any).decrypt(encrypted);\n\n      expect(decrypted).toBe(original);\n      expect(encrypted).not.toBe(original);\n      expect(encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);\n    });\n\n    it('should produce different encrypted output each time', () => {\n      const original = 'test-data';\n      const encrypted1 = (storage as any).encrypt(original);\n      const encrypted2 = (storage as any).encrypt(original);\n\n      expect(encrypted1).not.toBe(encrypted2);\n      expect((storage as any).decrypt(encrypted1)).toBe(original);\n      expect((storage as any).decrypt(encrypted2)).toBe(original);\n    });\n\n    it('should throw on invalid encrypted data format', () => {\n      expect(() => (storage as any).decrypt('invalid-data')).toThrow(\n        'Invalid encrypted data format',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  beforeEach,\n  afterEach,\n  jest,\n} from '@jest/globals';\nimport {\n  type OAuthCredentials,\n  TokenStorageType,\n} from '../../../auth/token-storage/types';\n\n// Mock paths\nconst KEYCHAIN_TOKEN_STORAGE_PATH =\n  '../../../auth/token-storage/keychain-token-storage';\nconst FILE_TOKEN_STORAGE_PATH =\n  '../../../auth/token-storage/file-token-storage';\nconst HYBRID_TOKEN_STORAGE_PATH =\n  '../../../auth/token-storage/hybrid-token-storage';\n\ninterface MockStorage {\n  isAvailable?: ReturnType<typeof jest.fn>;\n  getCredentials: ReturnType<typeof jest.fn>;\n  setCredentials: ReturnType<typeof jest.fn>;\n  deleteCredentials: ReturnType<typeof jest.fn>;\n  listServers: ReturnType<typeof jest.fn>;\n  getAllCredentials: ReturnType<typeof jest.fn>;\n  clearAll: ReturnType<typeof jest.fn>;\n}\n\ndescribe('HybridTokenStorage', () => {\n  let HybridTokenStorage: typeof import('../../../auth/token-storage/hybrid-token-storage').HybridTokenStorage;\n  let storage: import('../../../auth/token-storage/hybrid-token-storage').HybridTokenStorage;\n  let mockKeychainStorage: MockStorage;\n  let mockFileStorage: MockStorage;\n  const originalEnv = process.env;\n\n  beforeEach(() => {\n    jest.resetModules();\n    process.env = { ...originalEnv };\n    process.env['GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE'] = 'false';\n\n    mockKeychainStorage = {\n      isAvailable: jest.fn(),\n      getCredentials: jest.fn(),\n      setCredentials: jest.fn(),\n      deleteCredentials: jest.fn(),\n      listServers: jest.fn(),\n      getAllCredentials: jest.fn(),\n      clearAll: jest.fn(),\n    };\n\n    mockFileStorage = {\n      getCredentials: jest.fn(),\n      setCredentials: jest.fn(),\n      deleteCredentials: jest.fn(),\n      listServers: jest.fn(),\n      getAllCredentials: jest.fn(),\n      clearAll: jest.fn(),\n    };\n\n    jest.doMock(KEYCHAIN_TOKEN_STORAGE_PATH, () => ({\n      KeychainTokenStorage: jest\n        .fn()\n        .mockImplementation(() => mockKeychainStorage),\n    }));\n\n    jest.mock(FILE_TOKEN_STORAGE_PATH, () => ({\n      FileTokenStorage: {\n        create: jest.fn().mockImplementation(() => {\n          return Promise.resolve(mockFileStorage);\n        }),\n      },\n    }));\n\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    HybridTokenStorage = require(HYBRID_TOKEN_STORAGE_PATH).HybridTokenStorage;\n    storage = new HybridTokenStorage('test-service');\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n  });\n\n  describe('storage selection', () => {\n    it('should use keychain when available', async () => {\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.getCredentials.mockResolvedValue(null);\n\n      await storage.getCredentials('test-server');\n\n      expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();\n      expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n      expect(await storage.getStorageType()).toBe(TokenStorageType.KEYCHAIN);\n    });\n\n    it('should use file storage when GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE is set', async () => {\n      process.env['GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE'] = 'true';\n      mockFileStorage.getCredentials.mockResolvedValue(null);\n\n      await storage.getCredentials('test-server');\n\n      expect(mockKeychainStorage.isAvailable).not.toHaveBeenCalled();\n      expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n      expect(await storage.getStorageType()).toBe(\n        TokenStorageType.ENCRYPTED_FILE,\n      );\n    });\n\n    it('should fall back to file storage when keychain is unavailable', async () => {\n      mockKeychainStorage.isAvailable!.mockResolvedValue(false);\n      mockFileStorage.getCredentials.mockResolvedValue(null);\n\n      await storage.getCredentials('test-server');\n\n      expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();\n      expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n      expect(await storage.getStorageType()).toBe(\n        TokenStorageType.ENCRYPTED_FILE,\n      );\n    });\n\n    it('should fall back to file storage when keychain throws error', async () => {\n      mockKeychainStorage.isAvailable!.mockRejectedValue(\n        new Error('Keychain error'),\n      );\n      mockFileStorage.getCredentials.mockResolvedValue(null);\n\n      await storage.getCredentials('test-server');\n\n      expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();\n      expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n      expect(await storage.getStorageType()).toBe(\n        TokenStorageType.ENCRYPTED_FILE,\n      );\n    });\n\n    it('should cache storage selection', async () => {\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.getCredentials.mockResolvedValue(null);\n\n      await storage.getCredentials('test-server');\n      await storage.getCredentials('another-server');\n\n      expect(mockKeychainStorage.isAvailable).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('getCredentials', () => {\n    it('should delegate to selected storage', async () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.getCredentials.mockResolvedValue(credentials);\n\n      const result = await storage.getCredentials('test-server');\n\n      expect(result).toEqual(credentials);\n      expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n    });\n  });\n\n  describe('setCredentials', () => {\n    it('should delegate to selected storage', async () => {\n      const credentials: OAuthCredentials = {\n        serverName: 'test-server',\n        token: {\n          accessToken: 'access-token',\n          tokenType: 'Bearer',\n        },\n        updatedAt: Date.now(),\n      };\n\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.setCredentials.mockResolvedValue(undefined);\n\n      await storage.setCredentials(credentials);\n\n      expect(mockKeychainStorage.setCredentials).toHaveBeenCalledWith(\n        credentials,\n      );\n    });\n  });\n\n  describe('deleteCredentials', () => {\n    it('should delegate to selected storage', async () => {\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.deleteCredentials.mockResolvedValue(undefined);\n\n      await storage.deleteCredentials('test-server');\n\n      expect(mockKeychainStorage.deleteCredentials).toHaveBeenCalledWith(\n        'test-server',\n      );\n    });\n  });\n\n  describe('listServers', () => {\n    it('should delegate to selected storage', async () => {\n      const servers = ['server1', 'server2'];\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.listServers.mockResolvedValue(servers);\n\n      const result = await storage.listServers();\n\n      expect(result).toEqual(servers);\n      expect(mockKeychainStorage.listServers).toHaveBeenCalled();\n    });\n  });\n\n  describe('getAllCredentials', () => {\n    it('should delegate to selected storage', async () => {\n      const credentialsMap = new Map([\n        [\n          'server1',\n          {\n            serverName: 'server1',\n            token: { accessToken: 'token1', tokenType: 'Bearer' },\n            updatedAt: Date.now(),\n          },\n        ],\n        [\n          'server2',\n          {\n            serverName: 'server2',\n            token: { accessToken: 'token2', tokenType: 'Bearer' },\n            updatedAt: Date.now(),\n          },\n        ],\n      ]);\n\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.getAllCredentials.mockResolvedValue(credentialsMap);\n\n      const result = await storage.getAllCredentials();\n\n      expect(result).toEqual(credentialsMap);\n      expect(mockKeychainStorage.getAllCredentials).toHaveBeenCalled();\n    });\n  });\n\n  describe('clearAll', () => {\n    it('should delegate to selected storage', async () => {\n      mockKeychainStorage.isAvailable!.mockResolvedValue(true);\n      mockKeychainStorage.clearAll.mockResolvedValue(undefined);\n\n      await storage.clearAll();\n\n      expect(mockKeychainStorage.clearAll).toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  beforeEach,\n  afterEach,\n  jest,\n} from '@jest/globals';\nimport type { KeychainTokenStorage } from '../../../auth/token-storage/keychain-token-storage';\nimport type { OAuthCredentials } from '../../../auth/token-storage/types';\nimport type keytar from 'keytar';\n\n// Mock the entire keytar module.\njest.mock('keytar');\n\n// We will get a reference to the mock inside `beforeEach`.\nlet mockKeytar: jest.Mocked<typeof keytar>;\n\nconst mockServiceName = 'service-name';\nconst mockCryptoRandomBytesString = 'random-string';\n\njest.mock('node:crypto', () => ({\n  randomBytes: jest.fn(() => ({\n    toString: jest.fn(() => mockCryptoRandomBytesString),\n  })),\n}));\n\ndescribe('KeychainTokenStorage', () => {\n  let storage: KeychainTokenStorage;\n\n  beforeEach(async () => {\n    jest.resetAllMocks();\n    // Reset modules to ensure a clean state for each test.\n    jest.resetModules();\n\n    // Dynamically import the mocked keytar and cast it to our mocked type.\n    // This MUST be done after resetting modules.\n    mockKeytar = (await import('keytar')).default as jest.Mocked<typeof keytar>;\n\n    // Now import the module we are testing, which will use the mock above.\n    const { KeychainTokenStorage } =\n      await import('../../../auth/token-storage/keychain-token-storage');\n    storage = new KeychainTokenStorage(mockServiceName);\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n    jest.useRealTimers();\n  });\n\n  const validCredentials = {\n    serverName: 'test-server',\n    token: {\n      accessToken: 'access-token',\n      tokenType: 'Bearer',\n      expiresAt: Date.now() + 3600000,\n    },\n    updatedAt: Date.now(),\n  } as OAuthCredentials;\n\n  describe('checkKeychainAvailability', () => {\n    it('should return true if keytar is available and functional', async () => {\n      mockKeytar.setPassword.mockResolvedValue(undefined);\n      mockKeytar.getPassword.mockResolvedValue('test');\n      mockKeytar.deletePassword.mockResolvedValue(true);\n\n      const isAvailable = await storage.checkKeychainAvailability();\n      expect(isAvailable).toBe(true);\n      expect(mockKeytar.setPassword).toHaveBeenCalledWith(\n        mockServiceName,\n        `__keychain_test__${mockCryptoRandomBytesString}`,\n        'test',\n      );\n      expect(mockKeytar.getPassword).toHaveBeenCalledWith(\n        mockServiceName,\n        `__keychain_test__${mockCryptoRandomBytesString}`,\n      );\n      expect(mockKeytar.deletePassword).toHaveBeenCalledWith(\n        mockServiceName,\n        `__keychain_test__${mockCryptoRandomBytesString}`,\n      );\n    });\n\n    it('should return false if keytar fails to set password', async () => {\n      mockKeytar.setPassword.mockRejectedValue(new Error('write error'));\n      const isAvailable = await storage.checkKeychainAvailability();\n      expect(isAvailable).toBe(false);\n    });\n\n    it('should return false if retrieved password does not match', async () => {\n      mockKeytar.setPassword.mockResolvedValue(undefined);\n      mockKeytar.getPassword.mockResolvedValue('wrong-password');\n      mockKeytar.deletePassword.mockResolvedValue(true);\n      const isAvailable = await storage.checkKeychainAvailability();\n      expect(isAvailable).toBe(false);\n    });\n\n    it('should cache the availability result', async () => {\n      mockKeytar.setPassword.mockResolvedValue(undefined);\n      mockKeytar.getPassword.mockResolvedValue('test');\n      mockKeytar.deletePassword.mockResolvedValue(true);\n\n      await storage.checkKeychainAvailability();\n      await storage.checkKeychainAvailability();\n\n      expect(mockKeytar.setPassword).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('with keychain unavailable', () => {\n    beforeEach(async () => {\n      // Force keychain to be unavailable\n      mockKeytar.setPassword.mockRejectedValue(new Error('keychain error'));\n      await storage.checkKeychainAvailability();\n    });\n\n    it('getCredentials should throw', async () => {\n      await expect(storage.getCredentials('server')).rejects.toThrow(\n        'Keychain is not available',\n      );\n    });\n\n    it('setCredentials should throw', async () => {\n      await expect(storage.setCredentials(validCredentials)).rejects.toThrow(\n        'Keychain is not available',\n      );\n    });\n\n    it('deleteCredentials should throw', async () => {\n      await expect(storage.deleteCredentials('server')).rejects.toThrow(\n        'Keychain is not available',\n      );\n    });\n\n    it('listServers should throw', async () => {\n      await expect(storage.listServers()).rejects.toThrow(\n        'Keychain is not available',\n      );\n    });\n\n    it('getAllCredentials should throw', async () => {\n      await expect(storage.getAllCredentials()).rejects.toThrow(\n        'Keychain is not available',\n      );\n    });\n  });\n\n  describe('with keychain available', () => {\n    beforeEach(async () => {\n      mockKeytar.setPassword.mockResolvedValue(undefined);\n      mockKeytar.getPassword.mockResolvedValue('test');\n      mockKeytar.deletePassword.mockResolvedValue(true);\n      await storage.checkKeychainAvailability();\n      // Reset mocks after availability check\n      jest.resetAllMocks();\n    });\n\n    describe('getCredentials', () => {\n      it('should return null if no credentials are found', async () => {\n        mockKeytar.getPassword.mockResolvedValue(null);\n        const result = await storage.getCredentials('test-server');\n        expect(result).toBeNull();\n        expect(mockKeytar.getPassword).toHaveBeenCalledWith(\n          mockServiceName,\n          'test-server',\n        );\n      });\n\n      it('should return credentials if found and not expired', async () => {\n        mockKeytar.getPassword.mockResolvedValue(\n          JSON.stringify(validCredentials),\n        );\n        const result = await storage.getCredentials('test-server');\n        expect(result).toEqual(validCredentials);\n      });\n\n      it('should return credentials even if access token is expired', async () => {\n        const expiredCreds = {\n          ...validCredentials,\n          token: { ...validCredentials.token, expiresAt: Date.now() - 1000 },\n        };\n        mockKeytar.getPassword.mockResolvedValue(JSON.stringify(expiredCreds));\n        const result = await storage.getCredentials('test-server');\n        expect(result).toEqual(expiredCreds);\n      });\n\n      it('should throw if stored data is corrupted JSON', async () => {\n        mockKeytar.getPassword.mockResolvedValue('not-json');\n        await expect(storage.getCredentials('test-server')).rejects.toThrow(\n          'Failed to parse stored credentials for test-server',\n        );\n      });\n    });\n\n    describe('setCredentials', () => {\n      it('should save credentials to keychain', async () => {\n        jest.useFakeTimers();\n        mockKeytar.setPassword.mockResolvedValue(undefined);\n        await storage.setCredentials(validCredentials);\n        expect(mockKeytar.setPassword).toHaveBeenCalledWith(\n          mockServiceName,\n          'test-server',\n          JSON.stringify({ ...validCredentials, updatedAt: Date.now() }),\n        );\n      });\n\n      it('should throw if saving to keychain fails', async () => {\n        mockKeytar.setPassword.mockRejectedValue(\n          new Error('keychain write error'),\n        );\n        await expect(storage.setCredentials(validCredentials)).rejects.toThrow(\n          'keychain write error',\n        );\n      });\n    });\n\n    describe('deleteCredentials', () => {\n      it('should delete credentials from keychain', async () => {\n        mockKeytar.deletePassword.mockResolvedValue(true);\n        await storage.deleteCredentials('test-server');\n        expect(mockKeytar.deletePassword).toHaveBeenCalledWith(\n          mockServiceName,\n          'test-server',\n        );\n      });\n\n      it('should throw if no credentials were found to delete', async () => {\n        mockKeytar.deletePassword.mockResolvedValue(false);\n        await expect(storage.deleteCredentials('test-server')).rejects.toThrow(\n          'No credentials found for test-server',\n        );\n      });\n\n      it('should throw if deleting from keychain fails', async () => {\n        mockKeytar.deletePassword.mockRejectedValue(\n          new Error('keychain delete error'),\n        );\n        await expect(storage.deleteCredentials('test-server')).rejects.toThrow(\n          'keychain delete error',\n        );\n      });\n    });\n\n    describe('listServers', () => {\n      it('should return a list of server names', async () => {\n        mockKeytar.findCredentials.mockResolvedValue([\n          { account: 'server1', password: '' },\n          { account: 'server2', password: '' },\n        ]);\n        const result = await storage.listServers();\n        expect(result).toEqual(['server1', 'server2']);\n      });\n\n      it('should not include internal test keys in the server list', async () => {\n        mockKeytar.findCredentials.mockResolvedValue([\n          { account: 'server1', password: '' },\n          {\n            account: `__keychain_test__${mockCryptoRandomBytesString}`,\n            password: '',\n          },\n          { account: 'server2', password: '' },\n        ]);\n        const result = await storage.listServers();\n        expect(result).toEqual(['server1', 'server2']);\n      });\n\n      it('should return an empty array on error', async () => {\n        mockKeytar.findCredentials.mockRejectedValue(new Error('find error'));\n        const result = await storage.listServers();\n        expect(result).toEqual([]);\n      });\n    });\n\n    describe('getAllCredentials', () => {\n      it('should return a map of all valid credentials', async () => {\n        const creds2 = {\n          ...validCredentials,\n          serverName: 'server2',\n        };\n        const expiredCreds = {\n          ...validCredentials,\n          serverName: 'expired-server',\n          token: { ...validCredentials.token, expiresAt: Date.now() - 1000 },\n        };\n        const structurallyInvalidCreds = {\n          serverName: 'invalid-server',\n        };\n\n        mockKeytar.findCredentials.mockResolvedValue([\n          {\n            account: 'test-server',\n            password: JSON.stringify(validCredentials),\n          },\n          { account: 'server2', password: JSON.stringify(creds2) },\n          {\n            account: 'expired-server',\n            password: JSON.stringify(expiredCreds),\n          },\n          { account: 'bad-server', password: 'not-json' },\n          {\n            account: 'invalid-server',\n            password: JSON.stringify(structurallyInvalidCreds),\n          },\n        ]);\n\n        const result = await storage.getAllCredentials();\n        expect(result.size).toBe(3);\n        expect(result.get('test-server')).toEqual(validCredentials);\n        expect(result.get('server2')).toEqual(creds2);\n        expect(result.get('expired-server')).toEqual(expiredCreds);\n        expect(result.has('bad-server')).toBe(false);\n        expect(result.has('invalid-server')).toBe(false);\n      });\n    });\n\n    describe('clearAll', () => {\n      it('should delete all credentials for the service', async () => {\n        mockKeytar.findCredentials.mockResolvedValue([\n          { account: 'server1', password: '' },\n          { account: 'server2', password: '' },\n        ]);\n        mockKeytar.deletePassword.mockResolvedValue(true);\n\n        await storage.clearAll();\n\n        expect(mockKeytar.deletePassword).toHaveBeenCalledTimes(2);\n        expect(mockKeytar.deletePassword).toHaveBeenCalledWith(\n          mockServiceName,\n          'server1',\n        );\n        expect(mockKeytar.deletePassword).toHaveBeenCalledWith(\n          mockServiceName,\n          'server2',\n        );\n      });\n\n      it('should throw an aggregated error if deletions fail', async () => {\n        mockKeytar.findCredentials.mockResolvedValue([\n          { account: 'server1', password: '' },\n          { account: 'server2', password: '' },\n        ]);\n        mockKeytar.deletePassword\n          .mockResolvedValueOnce(true)\n          .mockRejectedValueOnce(new Error('delete failed'));\n\n        await expect(storage.clearAll()).rejects.toThrow(\n          'Failed to clear some credentials: delete failed',\n        );\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, beforeEach, jest } from '@jest/globals';\nimport { OAuthCredentialStorage } from '../../../auth/token-storage/oauth-credential-storage';\nimport { HybridTokenStorage } from '../../../auth/token-storage/hybrid-token-storage';\nimport { type Credentials } from 'google-auth-library';\nimport { type OAuthCredentials } from '../../../auth/token-storage/types';\n\n// Mock the HybridTokenStorage dependency\njest.mock('../../../auth/token-storage/hybrid-token-storage');\n\ndescribe('OAuthCredentialStorage', () => {\n  const mockGoogleCredentials: Credentials = {\n    access_token: 'test-access-token',\n    refresh_token: 'test-refresh-token',\n    expiry_date: 1234567890,\n    token_type: 'Bearer',\n    scope: 'test-scope',\n  };\n\n  const mockMcpCredentials: OAuthCredentials = {\n    serverName: 'main-account',\n    token: {\n      accessToken: 'test-access-token',\n      refreshToken: 'test-refresh-token',\n      expiresAt: 1234567890,\n      tokenType: 'Bearer',\n      scope: 'test-scope',\n    },\n    updatedAt: expect.any(Number) as any,\n  };\n\n  let getCredentialsMock: any;\n  let setCredentialsMock: any;\n  let deleteCredentialsMock: any;\n\n  beforeEach(() => {\n    jest.clearAllMocks();\n\n    getCredentialsMock = jest\n      .spyOn(HybridTokenStorage.prototype, 'getCredentials')\n      .mockResolvedValue(null);\n    setCredentialsMock = jest\n      .spyOn(HybridTokenStorage.prototype, 'setCredentials')\n      .mockResolvedValue(undefined);\n    deleteCredentialsMock = jest\n      .spyOn(HybridTokenStorage.prototype, 'deleteCredentials')\n      .mockResolvedValue(undefined);\n  });\n\n  describe('loadCredentials', () => {\n    it('should load credentials from HybridTokenStorage if available', async () => {\n      getCredentialsMock.mockResolvedValue(mockMcpCredentials);\n\n      const credentials = await OAuthCredentialStorage.loadCredentials();\n\n      expect(getCredentialsMock).toHaveBeenCalledWith('main-account');\n      expect(credentials).toEqual(mockGoogleCredentials);\n    });\n\n    it('should return null if no credentials found', async () => {\n      getCredentialsMock.mockResolvedValue(null);\n\n      const credentials = await OAuthCredentialStorage.loadCredentials();\n\n      expect(getCredentialsMock).toHaveBeenCalledWith('main-account');\n      expect(credentials).toBeNull();\n    });\n\n    it('should throw an error if loading fails', async () => {\n      getCredentialsMock.mockRejectedValue(new Error('Storage error'));\n\n      await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow(\n        'Storage error',\n      );\n    });\n  });\n\n  describe('saveCredentials', () => {\n    it('should save credentials to HybridTokenStorage', async () => {\n      setCredentialsMock.mockResolvedValue(undefined);\n\n      await OAuthCredentialStorage.saveCredentials(mockGoogleCredentials);\n\n      expect(setCredentialsMock).toHaveBeenCalledWith(mockMcpCredentials);\n    });\n  });\n\n  describe('clearCredentials', () => {\n    it('should delete credentials from HybridTokenStorage', async () => {\n      deleteCredentialsMock.mockResolvedValue(undefined);\n\n      await OAuthCredentialStorage.clearCredentials();\n\n      expect(deleteCredentialsMock).toHaveBeenCalledWith('main-account');\n    });\n\n    it('should throw an error if clearing from HybridTokenStorage fails', async () => {\n      deleteCredentialsMock.mockRejectedValue(new Error('Clear error'));\n\n      await expect(OAuthCredentialStorage.clearCredentials()).rejects.toThrow(\n        'Clear error',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/features/feature-config.test.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { execSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { describe, it, expect } from '@jest/globals';\nimport {\n  FEATURE_GROUPS,\n  featureGroupKey,\n  getAllPossibleScopes,\n} from '../../features/feature-config';\n\ndescribe('feature-config', () => {\n  it('should have unique feature group keys', () => {\n    const keys = FEATURE_GROUPS.map(featureGroupKey);\n    expect(keys.length).toBe(new Set(keys).size);\n  });\n\n  it('should not have duplicate tool names across groups', () => {\n    const allTools: string[] = [];\n    for (const fg of FEATURE_GROUPS) {\n      allTools.push(...fg.tools);\n    }\n    const duplicates = allTools.filter(\n      (tool, i) => allTools.indexOf(tool) !== i,\n    );\n    expect(duplicates).toEqual([]);\n  });\n\n  it('should have slides.write, sheets.write, tasks.read, and tasks.write defaulted to OFF', () => {\n    const offByDefault = FEATURE_GROUPS.filter((fg) => !fg.defaultEnabled).map(\n      featureGroupKey,\n    );\n    expect(offByDefault).toContain('slides.write');\n    expect(offByDefault).toContain('sheets.write');\n    expect(offByDefault).toContain('tasks.read');\n    expect(offByDefault).toContain('tasks.write');\n  });\n\n  it('should have all default-ON services with at least one tool', () => {\n    const defaultOnWithNoTools = FEATURE_GROUPS.filter(\n      (fg) => fg.defaultEnabled && fg.tools.length === 0,\n    );\n    expect(defaultOnWithNoTools).toEqual([]);\n  });\n\n  it('should have valid scope URLs', () => {\n    for (const fg of FEATURE_GROUPS) {\n      for (const scope of fg.scopes) {\n        expect(scope).toMatch(/^https:\\/\\/www\\.googleapis\\.com\\/auth\\//);\n      }\n    }\n  });\n\n  it('should have time.read with no scopes', () => {\n    const timeRead = FEATURE_GROUPS.find(\n      (fg) => fg.service === 'time' && fg.group === 'read',\n    );\n    expect(timeRead).toBeDefined();\n    expect(timeRead!.scopes).toEqual([]);\n  });\n});\n\ndescribe('getAllPossibleScopes (issue #323)', () => {\n  it('should include both write and readonly scopes for paired groups', () => {\n    const scopes = getAllPossibleScopes();\n    // Both must be registered on the consent screen — users may flip\n    // <service>.write off, which causes <service>.readonly to be requested.\n    expect(scopes).toContain('https://www.googleapis.com/auth/drive');\n    expect(scopes).toContain('https://www.googleapis.com/auth/drive.readonly');\n    expect(scopes).toContain('https://www.googleapis.com/auth/gmail.modify');\n    expect(scopes).toContain('https://www.googleapis.com/auth/gmail.readonly');\n    expect(scopes).toContain('https://www.googleapis.com/auth/calendar');\n    expect(scopes).toContain(\n      'https://www.googleapis.com/auth/calendar.readonly',\n    );\n  });\n\n  it('should exclude default-OFF group scopes that are not in any default-ON group', () => {\n    const scopes = getAllPossibleScopes();\n    // tasks.* are default-OFF and their scopes are not shared with any\n    // default-ON group, so they shouldn't be in the registration list.\n    expect(scopes).not.toContain('https://www.googleapis.com/auth/tasks');\n    expect(scopes).not.toContain(\n      'https://www.googleapis.com/auth/tasks.readonly',\n    );\n  });\n\n  it('should be sorted and deduplicated', () => {\n    const scopes = getAllPossibleScopes();\n    const sortedUnique = [...new Set(scopes)].sort();\n    expect(scopes).toEqual(sortedUnique);\n  });\n\n  it('print-scopes.ts should emit the same list (drift guard for setup-gcp.sh)', () => {\n    // setup-gcp.sh shells out to scripts/print-scopes.ts; if this test\n    // fails, the consent screen registration list will drift from\n    // FEATURE_GROUPS — which is the bug in issue #323.\n    const repoRoot = join(__dirname, '..', '..', '..', '..');\n    // execSync (not execFileSync) so Windows can resolve npx.cmd via the\n    // shell. Tests run on ubuntu/macos/windows.\n    const output = execSync(\n      'npx --no-install ts-node --transpile-only --project scripts/tsconfig.json scripts/print-scopes.ts',\n      { cwd: repoRoot, encoding: 'utf8' },\n    );\n    const printed = output.trim().split(/\\r?\\n/);\n    expect(printed).toEqual(getAllPossibleScopes());\n  });\n\n  it('setup-gcp.sh should not contain a hardcoded SCOPES list (drift guard)', () => {\n    // If someone re-inlines the list, this test catches it.\n    const repoRoot = join(__dirname, '..', '..', '..', '..');\n    const setupScript = readFileSync(\n      join(repoRoot, 'scripts', 'setup-gcp.sh'),\n      'utf8',\n    );\n    // A hardcoded list would have a literal scope URL inside SCOPES=( ... ).\n    const hardcodedMatch = setupScript.match(\n      /SCOPES=\\(\\s*\"https:\\/\\/www\\.googleapis\\.com\\//,\n    );\n    expect(hardcodedMatch).toBeNull();\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/features/feature-resolver.test.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect, jest } from '@jest/globals';\n\njest.mock('../../utils/logger', () => ({\n  logToFile: jest.fn(),\n}));\n\nimport {\n  resolveFeatures,\n  parseOverrides,\n} from '../../features/feature-resolver';\nimport { FEATURE_GROUPS } from '../../features/feature-config';\n\ndescribe('parseOverrides', () => {\n  it('should parse valid overrides', () => {\n    const result = parseOverrides('gmail.write:off,slides.write:on');\n    expect(result).toEqual([\n      { key: 'gmail.write', enabled: false },\n      { key: 'slides.write', enabled: true },\n    ]);\n  });\n\n  it('should handle whitespace', () => {\n    const result = parseOverrides(' gmail.write : off , slides.write : on ');\n    expect(result).toEqual([\n      { key: 'gmail.write', enabled: false },\n      { key: 'slides.write', enabled: true },\n    ]);\n  });\n\n  it('should skip empty entries', () => {\n    const result = parseOverrides('gmail.write:off,,slides.write:on,');\n    expect(result).toHaveLength(2);\n  });\n\n  it('should skip malformed entries (no colon)', () => {\n    const result = parseOverrides('gmail.write:off,badentry,slides.write:on');\n    expect(result).toHaveLength(2);\n  });\n\n  it('should skip entries with invalid values', () => {\n    const result = parseOverrides('gmail.write:off,slides.write:maybe');\n    expect(result).toHaveLength(1);\n    expect(result[0]).toEqual({ key: 'gmail.write', enabled: false });\n  });\n\n  it('should handle empty string', () => {\n    expect(parseOverrides('')).toEqual([]);\n  });\n\n  it('should handle tool-level overrides', () => {\n    const result = parseOverrides('calendar.deleteEvent:off,gmail.send:off');\n    expect(result).toEqual([\n      { key: 'calendar.deleteEvent', enabled: false },\n      { key: 'gmail.send', enabled: false },\n    ]);\n  });\n});\n\ndescribe('resolveFeatures', () => {\n  it('should return all default-ON tools when no overrides', () => {\n    const { enabledTools } = resolveFeatures();\n\n    // All tools from default-ON groups should be present\n    for (const fg of FEATURE_GROUPS) {\n      if (fg.defaultEnabled) {\n        for (const tool of fg.tools) {\n          expect(enabledTools.has(tool)).toBe(true);\n        }\n      }\n    }\n\n    // No tools from default-OFF groups\n    for (const fg of FEATURE_GROUPS) {\n      if (!fg.defaultEnabled) {\n        for (const tool of fg.tools) {\n          expect(enabledTools.has(tool)).toBe(false);\n        }\n      }\n    }\n  });\n\n  it('should compute scopes only for enabled groups', () => {\n    const { requiredScopes } = resolveFeatures();\n\n    // Default-OFF groups should not contribute scopes\n    const offGroups = FEATURE_GROUPS.filter((fg) => !fg.defaultEnabled);\n    for (const fg of offGroups) {\n      for (const scope of fg.scopes) {\n        // Only check if scope is absent when it's not also in an ON group\n        const inOnGroup = FEATURE_GROUPS.some(\n          (other) => other.defaultEnabled && other.scopes.includes(scope),\n        );\n        if (!inOnGroup) {\n          expect(requiredScopes).not.toContain(scope);\n        }\n      }\n    }\n  });\n\n  it('should disable a group via env override', () => {\n    const { enabledTools, requiredScopes } = resolveFeatures(\n      undefined,\n      'gmail.write:off',\n    );\n\n    // Gmail write tools should be absent\n    expect(enabledTools.has('gmail.send')).toBe(false);\n    expect(enabledTools.has('gmail.createDraft')).toBe(false);\n    expect(enabledTools.has('gmail.modify')).toBe(false);\n\n    // Gmail read tools should still be present\n    expect(enabledTools.has('gmail.search')).toBe(true);\n    expect(enabledTools.has('gmail.get')).toBe(true);\n\n    // gmail.readonly scope should be present (from gmail.read)\n    expect(requiredScopes).toContain(\n      'https://www.googleapis.com/auth/gmail.readonly',\n    );\n  });\n\n  it('should enable a default-OFF group via env override', () => {\n    const { requiredScopes } = resolveFeatures(undefined, 'tasks.read:on');\n\n    // tasks.read scopes should be present\n    expect(requiredScopes).toContain(\n      'https://www.googleapis.com/auth/tasks.readonly',\n    );\n  });\n\n  it('should disable individual tools via tool-level override', () => {\n    const { enabledTools } = resolveFeatures(\n      undefined,\n      'calendar.deleteEvent:off',\n    );\n\n    // calendar.deleteEvent should be disabled\n    expect(enabledTools.has('calendar.deleteEvent')).toBe(false);\n\n    // Other calendar write tools should still be present\n    expect(enabledTools.has('calendar.createEvent')).toBe(true);\n    expect(enabledTools.has('calendar.updateEvent')).toBe(true);\n  });\n\n  it('should ignore tool-level :on overrides (subtractive only)', () => {\n    // Disable gmail.write group, then try to re-enable gmail.send\n    const { enabledTools } = resolveFeatures(\n      undefined,\n      'gmail.write:off,gmail.send:on',\n    );\n\n    // gmail.send should still be disabled because its group is off\n    // and tool-level :on is ignored\n    expect(enabledTools.has('gmail.send')).toBe(false);\n  });\n\n  it('should apply settings overrides (layer 2)', () => {\n    const { enabledTools } = resolveFeatures(\n      { 'gmail.write': false },\n      undefined,\n    );\n\n    expect(enabledTools.has('gmail.send')).toBe(false);\n    expect(enabledTools.has('gmail.search')).toBe(true);\n  });\n\n  it('should give env overrides precedence over settings', () => {\n    // Settings disables gmail.write, but env re-enables it\n    const { enabledTools } = resolveFeatures(\n      { 'gmail.write': false },\n      'gmail.write:on',\n    );\n\n    expect(enabledTools.has('gmail.send')).toBe(true);\n  });\n\n  it('should combine group and tool-level overrides', () => {\n    const { enabledTools } = resolveFeatures(\n      undefined,\n      'calendar.deleteEvent:off,gmail.send:off',\n    );\n\n    expect(enabledTools.has('calendar.deleteEvent')).toBe(false);\n    expect(enabledTools.has('calendar.createEvent')).toBe(true);\n    expect(enabledTools.has('gmail.send')).toBe(false);\n    expect(enabledTools.has('gmail.createDraft')).toBe(true);\n  });\n\n  it('should deduplicate scopes', () => {\n    const { requiredScopes } = resolveFeatures();\n    const unique = new Set(requiredScopes);\n    expect(requiredScopes.length).toBe(unique.size);\n  });\n\n  describe('read/write scope dedup (issue #323)', () => {\n    const READONLY_PAIRS: Array<[string, string]> = [\n      ['drive.readonly', 'drive'],\n      ['calendar.readonly', 'calendar'],\n      ['chat.spaces.readonly', 'chat.spaces'],\n      ['chat.messages.readonly', 'chat.messages'],\n      ['chat.memberships.readonly', 'chat.memberships'],\n      ['gmail.readonly', 'gmail.modify'],\n    ];\n    const fullUrl = (s: string) => `https://www.googleapis.com/auth/${s}`;\n\n    it.each(READONLY_PAIRS)(\n      'should not request %s when paired write scope is enabled (defaults)',\n      (readonlyScope, writeScope) => {\n        const { requiredScopes } = resolveFeatures();\n        expect(requiredScopes).not.toContain(fullUrl(readonlyScope));\n        expect(requiredScopes).toContain(fullUrl(writeScope));\n      },\n    );\n\n    it.each([\n      ['drive.write:off', 'drive.readonly'],\n      ['calendar.write:off', 'calendar.readonly'],\n      ['gmail.write:off', 'gmail.readonly'],\n      ['chat.write:off', 'chat.spaces.readonly'],\n    ])(\n      'should request readonly scope when write group disabled (%s)',\n      (override, readonlyScope) => {\n        const { requiredScopes } = resolveFeatures(undefined, override);\n        expect(requiredScopes).toContain(fullUrl(readonlyScope));\n      },\n    );\n\n    it('should not affect tool registration — read tools stay enabled when write is on', () => {\n      const { enabledTools } = resolveFeatures();\n      expect(enabledTools.has('drive.search')).toBe(true);\n      expect(enabledTools.has('gmail.search')).toBe(true);\n      expect(enabledTools.has('calendar.list')).toBe(true);\n      expect(enabledTools.has('chat.listSpaces')).toBe(true);\n    });\n\n    it('should still include read scopes for services without a write group (people)', () => {\n      const { requiredScopes } = resolveFeatures();\n      expect(requiredScopes).toContain(fullUrl('directory.readonly'));\n      expect(requiredScopes).toContain(fullUrl('userinfo.profile'));\n    });\n\n    it('should still include readonly scopes for default-OFF write groups (slides, sheets)', () => {\n      const { requiredScopes } = resolveFeatures();\n      expect(requiredScopes).toContain(fullUrl('presentations.readonly'));\n      expect(requiredScopes).toContain(fullUrl('spreadsheets.readonly'));\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/mocks/wasm.js",
    "content": "/**\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",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { CalendarService } from '../../services/CalendarService';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('CalendarService', () => {\n  let calendarService: CalendarService;\n  let mockAuthManager: any;\n  let mockCalendarAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    };\n\n    // Create mock Calendar API\n    mockCalendarAPI = {\n      calendarList: {\n        list: jest.fn(),\n      },\n      events: {\n        list: jest.fn(),\n        insert: jest.fn(),\n        update: jest.fn(),\n        delete: jest.fn(),\n        get: jest.fn(),\n        patch: jest.fn(),\n      },\n      freebusy: {\n        query: jest.fn(),\n      },\n    };\n\n    // Mock the google.calendar constructor\n    (google.calendar as jest.Mock) = jest.fn().mockReturnValue(mockCalendarAPI);\n\n    // Create CalendarService instance\n    calendarService = new CalendarService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient);\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n    expect(mockCalendarAPI.events.update).not.toHaveBeenCalled();\n  });\n\n  describe('listCalendars', () => {\n    it('should list all calendars', async () => {\n      const mockCalendars = [\n        { id: 'primary', summary: 'Primary Calendar' },\n        { id: 'work', summary: 'Work Calendar' },\n        { id: 'personal', summary: 'Personal Calendar' },\n      ];\n\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: mockCalendars,\n        },\n      });\n\n      const result = await calendarService.listCalendars();\n\n      expect(mockCalendarAPI.calendarList.list).toHaveBeenCalledTimes(1);\n\n      const expectedResult = mockCalendars.map((c) => ({\n        id: c.id,\n        summary: c.summary,\n      }));\n      expect(JSON.parse(result.content[0].text)).toEqual(expectedResult);\n    });\n\n    it('should handle empty calendar list', async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [],\n        },\n      });\n\n      const result = await calendarService.listCalendars();\n\n      expect(mockCalendarAPI.calendarList.list).toHaveBeenCalledTimes(1);\n      expect(JSON.parse(result.content[0].text)).toEqual([]);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Calendar API failed');\n      mockCalendarAPI.calendarList.list.mockRejectedValue(apiError);\n\n      const result = await calendarService.listCalendars();\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Calendar API failed',\n      });\n    });\n\n    it('should handle undefined items in response', async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {},\n      });\n\n      const result = await calendarService.listCalendars();\n\n      expect(JSON.parse(result.content[0].text)).toEqual([]);\n    });\n  });\n\n  describe('createEvent', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n    });\n\n    it('should create a calendar event without a calendarId', async () => {\n      const eventInput = {\n        summary: 'Team Meeting',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary-calendar-id',\n        requestBody: {\n          summary: 'Team Meeting',\n          start: eventInput.start,\n          end: eventInput.end,\n        },\n        sendUpdates: 'none',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('creates a single-day all-day workingLocation event', async () => {\n      mockCalendarAPI.events.insert.mockResolvedValue({ data: { id: 'wl1' } });\n      await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'homeOffice' },\n      });\n      const body = mockCalendarAPI.events.insert.mock.calls[0][0].requestBody;\n      expect(body.start).toEqual({ date: '2024-01-15' });\n      expect(body.end).toEqual({ date: '2024-01-16' });\n      expect(body.eventType).toBe('workingLocation');\n    });\n\n    it('should create a calendar event', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Team Meeting',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        requestBody: {\n          summary: 'Team Meeting',\n          start: eventInput.start,\n          end: eventInput.end,\n        },\n        sendUpdates: 'none',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create a calendar event with a description', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Team Meeting',\n        description: 'Monthly strategy sync',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        description: 'Monthly strategy sync',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        requestBody: {\n          summary: 'Team Meeting',\n          description: 'Monthly strategy sync',\n          start: eventInput.start,\n          end: eventInput.end,\n        },\n        sendUpdates: 'none',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create a calendar event with sendUpdates parameter', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Team Meeting',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n        attendees: ['test@example.com'],\n        sendUpdates: 'all' as const,\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        requestBody: {\n          summary: 'Team Meeting',\n          start: eventInput.start,\n          end: eventInput.end,\n          attendees: [{ email: 'test@example.com' }],\n        },\n        sendUpdates: 'all',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should default sendUpdates to \"all\" when attendees are present but sendUpdates is not provided', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Team Meeting',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n        attendees: ['test@example.com'],\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        requestBody: {\n          summary: 'Team Meeting',\n          start: eventInput.start,\n          end: eventInput.end,\n          attendees: [{ email: 'test@example.com' }],\n        },\n        sendUpdates: 'all',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should default sendUpdates to \"none\" when no attendees are present and sendUpdates is not provided', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Solo Working Session',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      const mockCreatedEvent = {\n        id: 'event123',\n        summary: 'Solo Working Session',\n        start: eventInput.start,\n        end: eventInput.end,\n        status: 'confirmed',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent(eventInput);\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        requestBody: {\n          summary: 'Solo Working Session',\n          start: eventInput.start,\n          end: eventInput.end,\n        },\n        sendUpdates: 'none',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should handle event creation errors', async () => {\n      const eventInput = {\n        calendarId: 'primary',\n        summary: 'Invalid Event',\n        start: { dateTime: 'invalid-date' },\n        end: { dateTime: 'invalid-date' },\n      };\n\n      // The validation now catches this before it reaches the API\n      const result = await calendarService.createEvent(eventInput);\n\n      const errorResponse = JSON.parse(result.content[0].text);\n      expect(errorResponse.error).toBe('Invalid input format');\n      expect(errorResponse.details).toContain(\n        'Invalid ISO 8601 datetime format',\n      );\n    });\n  });\n\n  describe('listEvents', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n    });\n\n    it('should list events for a calendar without a calendarId', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Meeting 1',\n          start: { dateTime: '2024-01-15T09:00:00Z' },\n          end: { dateTime: '2024-01-15T10:00:00Z' },\n          status: 'confirmed',\n        },\n        {\n          id: 'event2',\n          summary: 'Meeting 2',\n          start: { dateTime: '2024-01-15T14:00:00Z' },\n          end: { dateTime: '2024-01-15T15:00:00Z' },\n          status: 'confirmed',\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        timeMin: '2024-01-15T00:00:00Z',\n        timeMax: '2024-01-16T00:00:00Z',\n      });\n\n      expect(mockCalendarAPI.events.list).toHaveBeenCalledWith({\n        calendarId: 'primary-calendar-id',\n        timeMin: '2024-01-15T00:00:00Z',\n        timeMax: '2024-01-16T00:00:00Z',\n        singleEvents: true,\n        fields:\n          'items(id,summary,start,end,description,htmlLink,attendees,status,eventType,focusTimeProperties,outOfOfficeProperties,workingLocationProperties)',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockEvents);\n    });\n\n    it('should list events for a calendar', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Meeting 1',\n          start: { dateTime: '2024-01-15T09:00:00Z' },\n          end: { dateTime: '2024-01-15T10:00:00Z' },\n          status: 'confirmed',\n        },\n        {\n          id: 'event2',\n          summary: 'Meeting 2',\n          start: { dateTime: '2024-01-15T14:00:00Z' },\n          end: { dateTime: '2024-01-15T15:00:00Z' },\n          status: 'confirmed',\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n        timeMin: '2024-01-15T00:00:00Z',\n        timeMax: '2024-01-16T00:00:00Z',\n      });\n\n      expect(mockCalendarAPI.events.list).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        timeMin: '2024-01-15T00:00:00Z',\n        timeMax: '2024-01-16T00:00:00Z',\n        singleEvents: true,\n        fields:\n          'items(id,summary,start,end,description,htmlLink,attendees,status,eventType,focusTimeProperties,outOfOfficeProperties,workingLocationProperties)',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockEvents);\n    });\n\n    it('should list events with a default timeMax', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Meeting 1',\n          start: { dateTime: '2024-01-15T09:00:00Z' },\n          end: { dateTime: '2024-01-15T10:00:00Z' },\n          status: 'confirmed',\n        },\n        {\n          id: 'event2',\n          summary: 'Meeting 2',\n          start: { dateTime: '2024-01-15T14:00:00Z' },\n          end: { dateTime: '2024-01-15T15:00:00Z' },\n          status: 'confirmed',\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n        timeMin: '2024-01-15T00:00:00Z',\n      });\n\n      expect(mockCalendarAPI.events.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          timeMax: expect.any(String),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockEvents);\n    });\n\n    it('should filter out cancelled events', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Active Meeting',\n          status: 'confirmed',\n        },\n        {\n          id: 'event2',\n          summary: 'Cancelled Meeting',\n          status: 'cancelled',\n        },\n        {\n          id: 'event3',\n          summary: 'Another Active Meeting',\n          status: 'confirmed',\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(2);\n      expect(parsedResult.map((e: any) => e.id)).toEqual(['event1', 'event3']);\n    });\n\n    it('should filter events based on attendee response status', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Meeting I accepted',\n          status: 'confirmed',\n          attendees: [\n            { email: 'me@example.com', self: true, responseStatus: 'accepted' },\n            { email: 'other@example.com', responseStatus: 'tentative' },\n          ],\n        },\n        {\n          id: 'event2',\n          summary: 'Meeting I declined',\n          status: 'confirmed',\n          attendees: [\n            { email: 'me@example.com', self: true, responseStatus: 'declined' },\n            { email: 'other@example.com', responseStatus: 'accepted' },\n          ],\n        },\n        {\n          id: 'event3',\n          summary: 'Meeting needs response',\n          status: 'confirmed',\n          attendees: [\n            {\n              email: 'me@example.com',\n              self: true,\n              responseStatus: 'needsAction',\n            },\n          ],\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n        attendeeResponseStatus: ['accepted', 'needsAction'],\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(2);\n      expect(parsedResult.map((e: any) => e.id)).toEqual(['event1', 'event3']);\n    });\n\n    it('should include events with no attendees', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Personal Task',\n          status: 'confirmed',\n          // No attendees property\n        },\n        {\n          id: 'event2',\n          summary: 'Meeting with attendees',\n          status: 'confirmed',\n          attendees: [\n            { email: 'me@example.com', self: true, responseStatus: 'accepted' },\n          ],\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(2);\n    });\n\n    it('should filter out events without summary', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Valid Event',\n          status: 'confirmed',\n        },\n        {\n          id: 'event2',\n          // No summary\n          status: 'confirmed',\n        },\n        {\n          id: 'event3',\n          summary: null,\n          status: 'confirmed',\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(1);\n      expect(parsedResult[0].id).toBe('event1');\n    });\n\n    it('should keep status events without summary (focusTime, outOfOffice, workingLocation)', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          // No summary, but has a non-default eventType\n          status: 'confirmed',\n          eventType: 'focusTime',\n          focusTimeProperties: { chatStatus: 'doNotDisturb' },\n        },\n        {\n          id: 'event2',\n          // No summary, no eventType — should be filtered out\n          status: 'confirmed',\n        },\n        {\n          id: 'event3',\n          status: 'confirmed',\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: { items: mockEvents },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(2);\n      expect(parsedResult.map((e: any) => e.id)).toEqual(['event1', 'event3']);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Events API failed');\n      mockCalendarAPI.events.list.mockRejectedValue(apiError);\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Events API failed',\n      });\n    });\n\n    it('should handle empty events list', async () => {\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: [],\n        },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual([]);\n    });\n\n    it('should use default attendeeResponseStatus when not provided', async () => {\n      const mockEvents = [\n        {\n          id: 'event1',\n          summary: 'Meeting',\n          status: 'confirmed',\n          attendees: [\n            { email: 'me@example.com', self: true, responseStatus: 'accepted' },\n          ],\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: {\n          items: mockEvents,\n        },\n      });\n\n      await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      expect(mockCalendarAPI.events.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary',\n        }),\n      );\n    });\n  });\n\n  describe('findFreeTime', () => {\n    it('should find a free time slot', async () => {\n      const busyData = {\n        'user1@example.com': {\n          busy: [\n            { start: '2024-01-15T09:00:00Z', end: '2024-01-15T10:00:00Z' },\n            { start: '2024-01-15T14:00:00Z', end: '2024-01-15T15:00:00Z' },\n          ],\n        },\n        'user2@example.com': {\n          busy: [\n            { start: '2024-01-15T10:30:00Z', end: '2024-01-15T11:30:00Z' },\n          ],\n        },\n      };\n\n      mockCalendarAPI.freebusy.query.mockResolvedValue({\n        data: { calendars: busyData },\n      });\n\n      const result = await calendarService.findFreeTime({\n        attendees: ['user1@example.com', 'user2@example.com'],\n        timeMin: '2024-01-15T08:00:00Z',\n        timeMax: '2024-01-15T18:00:00Z',\n        duration: 60,\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.start).toBeDefined();\n      expect(parsedResult.end).toBeDefined();\n      expect(\n        new Date(parsedResult.end).getTime() -\n          new Date(parsedResult.start).getTime(),\n      ).toBe(60 * 60 * 1000);\n    });\n\n    it('should return an error if no free time is found', async () => {\n      const busyData = {\n        'user1@example.com': {\n          busy: [\n            { start: '2024-01-15T08:00:00Z', end: '2024-01-15T18:00:00Z' },\n          ],\n        },\n      };\n\n      mockCalendarAPI.freebusy.query.mockResolvedValue({\n        data: { calendars: busyData },\n      });\n\n      const result = await calendarService.findFreeTime({\n        attendees: ['user1@example.com'],\n        timeMin: '2024-01-15T08:00:00Z',\n        timeMax: '2024-01-15T18:00:00Z',\n        duration: 60,\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('No available free time found');\n    });\n\n    it('should handle the \"me\" attendee', async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n\n      const busyData = {\n        'primary-calendar-id': {\n          busy: [],\n        },\n      };\n\n      mockCalendarAPI.freebusy.query.mockResolvedValue({\n        data: { calendars: busyData },\n      });\n\n      const result = await calendarService.findFreeTime({\n        attendees: ['me'],\n        timeMin: '2024-01-15T08:00:00Z',\n        timeMax: '2024-01-15T18:00:00Z',\n        duration: 30,\n      });\n\n      expect(mockCalendarAPI.freebusy.query).toHaveBeenCalledWith({\n        requestBody: {\n          items: [{ id: 'primary-calendar-id' }],\n          timeMin: '2024-01-15T08:00:00Z',\n          timeMax: '2024-01-15T18:00:00Z',\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.start).toBeDefined();\n    });\n  });\n\n  describe('updateEvent', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary', primary: true }],\n        },\n      });\n    });\n\n    it('should update an event', async () => {\n      const updatedEvent = {\n        id: 'event123',\n        summary: 'Updated Meeting',\n        start: { dateTime: '2024-01-15T14:00:00Z' },\n        end: { dateTime: '2024-01-15T15:00:00Z' },\n        attendees: [{ email: 'new@example.com' }],\n      };\n\n      mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n      const result = await calendarService.updateEvent({\n        eventId: 'event123',\n        summary: 'Updated Meeting',\n        start: { dateTime: '2024-01-15T14:00:00Z' },\n        end: { dateTime: '2024-01-15T15:00:00Z' },\n        attendees: ['new@example.com'],\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        requestBody: {\n          summary: 'Updated Meeting',\n          start: { dateTime: '2024-01-15T14:00:00Z' },\n          end: { dateTime: '2024-01-15T15:00:00Z' },\n          attendees: [{ email: 'new@example.com' }],\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.id).toBe('event123');\n      expect(parsedResult.summary).toBe('Updated Meeting');\n    });\n\n    it('should update an event with a description', async () => {\n      const updatedEvent = {\n        id: 'event123',\n        description: 'New updated description',\n      };\n\n      mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n      const result = await calendarService.updateEvent({\n        eventId: 'event123',\n        description: 'New updated description',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        requestBody: {\n          description: 'New updated description',\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.description).toBe('New updated description');\n    });\n\n    it('should handle update errors', async () => {\n      const apiError = new Error('Update failed');\n      mockCalendarAPI.events.patch.mockRejectedValue(apiError);\n\n      const result = await calendarService.updateEvent({\n        eventId: 'event123',\n        summary: 'Updated Meeting',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Update failed');\n    });\n\n    it('should surface structured Google API field errors on update', async () => {\n      mockCalendarAPI.events.patch.mockRejectedValue({\n        response: {\n          data: {\n            error: {\n              message: 'Invalid Value',\n              code: 400,\n              errors: [\n                {\n                  location: 'start.dateTime',\n                  reason: 'invalid',\n                  message: 'Invalid start time',\n                },\n                {\n                  location: 'attendees',\n                  reason: 'invalid',\n                  message: 'Attendee email is invalid',\n                },\n              ],\n            },\n          },\n        },\n      });\n\n      const result = await calendarService.updateEvent({\n        eventId: 'event123',\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe(\n        'Invalid Value (code 400): start.dateTime invalid: Invalid start time; attendees invalid: Attendee email is invalid',\n      );\n    });\n\n    it('should only send fields that are provided', async () => {\n      const updatedEvent = {\n        id: 'event123',\n        summary: 'Updated Meeting Only',\n      };\n\n      mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n      await calendarService.updateEvent({\n        eventId: 'event123',\n        summary: 'Updated Meeting Only',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        requestBody: {\n          summary: 'Updated Meeting Only',\n        },\n      });\n    });\n\n    it.each([\n      ['summary', { summary: 'X' }],\n      ['description', { description: 'X' }],\n      ['start', { start: { dateTime: '2024-01-15T10:00:00Z' } }],\n      ['end', { end: { dateTime: '2024-01-15T11:00:00Z' } }],\n      ['attendees', { attendees: ['a@b.com'] }],\n    ])(\n      '#313: patch body for %s update contains only that field',\n      async (field, patch) => {\n        mockCalendarAPI.events.patch.mockResolvedValue({ data: { id: 'e' } });\n        await calendarService.updateEvent({ eventId: 'e', ...patch });\n        const body = mockCalendarAPI.events.patch.mock.calls[0][0].requestBody;\n        expect(Object.keys(body)).toEqual([field]);\n      },\n    );\n\n    it('should clear description when passed an empty string', async () => {\n      mockCalendarAPI.events.patch.mockResolvedValue({\n        data: { id: 'event123', description: '' },\n      });\n\n      await calendarService.updateEvent({\n        eventId: 'event123',\n        description: '',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        requestBody: {\n          description: '',\n        },\n      });\n    });\n\n    it('should clear attendees when passed an empty array', async () => {\n      mockCalendarAPI.events.patch.mockResolvedValue({\n        data: { id: 'event123', attendees: [] },\n      });\n\n      await calendarService.updateEvent({\n        eventId: 'event123',\n        attendees: [],\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        requestBody: {\n          attendees: [],\n        },\n      });\n    });\n  });\n\n  describe('respondToEvent', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary', primary: true }],\n        },\n      });\n    });\n\n    it('should accept a meeting invitation', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        attendees: [\n          {\n            email: 'me@example.com',\n            self: true,\n            responseStatus: 'needsAction',\n          },\n          { email: 'other@example.com', responseStatus: 'accepted' },\n        ],\n      };\n\n      const updatedEvent = {\n        ...mockEvent,\n        attendees: [\n          { email: 'me@example.com', self: true, responseStatus: 'accepted' },\n          { email: 'other@example.com', responseStatus: 'accepted' },\n        ],\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n      mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'accepted',\n      });\n\n      expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        sendNotifications: true,\n        requestBody: {\n          attendees: expect.arrayContaining([\n            expect.objectContaining({\n              email: 'me@example.com',\n              self: true,\n              responseStatus: 'accepted',\n            }),\n          ]),\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.eventId).toBe('event123');\n      expect(parsedResult.responseStatus).toBe('accepted');\n      expect(parsedResult.message).toContain('Successfully accepted');\n    });\n\n    it('should decline a meeting invitation with a message', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        attendees: [\n          {\n            email: 'me@example.com',\n            self: true,\n            responseStatus: 'needsAction',\n          },\n          { email: 'other@example.com', responseStatus: 'accepted' },\n        ],\n      };\n\n      const updatedEvent = {\n        ...mockEvent,\n        attendees: [\n          {\n            email: 'me@example.com',\n            self: true,\n            responseStatus: 'declined',\n            comment: 'Sorry, I have a conflict',\n          },\n          { email: 'other@example.com', responseStatus: 'accepted' },\n        ],\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n      mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'declined',\n        responseMessage: 'Sorry, I have a conflict',\n      });\n\n      expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        sendNotifications: true,\n        requestBody: {\n          attendees: expect.arrayContaining([\n            expect.objectContaining({\n              email: 'me@example.com',\n              self: true,\n              responseStatus: 'declined',\n              comment: 'Sorry, I have a conflict',\n            }),\n          ]),\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.responseStatus).toBe('declined');\n      expect(parsedResult.message).toContain('with message');\n    });\n\n    it('should mark attendance as tentative', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        attendees: [\n          {\n            email: 'me@example.com',\n            self: true,\n            responseStatus: 'needsAction',\n          },\n        ],\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n      mockCalendarAPI.events.patch.mockResolvedValue({\n        data: {\n          ...mockEvent,\n          attendees: [\n            { ...mockEvent.attendees[0], responseStatus: 'tentative' },\n          ],\n        },\n      });\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'tentative',\n        sendNotification: false,\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n        sendNotifications: false,\n        requestBody: {\n          attendees: expect.arrayContaining([\n            expect.objectContaining({\n              responseStatus: 'tentative',\n            }),\n          ]),\n        },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.responseStatus).toBe('tentative');\n    });\n\n    it('should handle events with no attendees', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Personal Event',\n        // No attendees\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'accepted',\n      });\n\n      expect(mockCalendarAPI.events.patch).not.toHaveBeenCalled();\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Event has no attendees');\n    });\n\n    it('should handle when user is not an attendee', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Meeting',\n        attendees: [\n          { email: 'other1@example.com', responseStatus: 'accepted' },\n          { email: 'other2@example.com', responseStatus: 'tentative' },\n        ],\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'accepted',\n      });\n\n      expect(mockCalendarAPI.events.patch).not.toHaveBeenCalled();\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('You are not an attendee of this event');\n    });\n\n    it('should use custom calendar ID when provided', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Team Meeting',\n        attendees: [\n          {\n            email: 'me@example.com',\n            self: true,\n            responseStatus: 'needsAction',\n          },\n        ],\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n      mockCalendarAPI.events.patch.mockResolvedValue({\n        data: {\n          ...mockEvent,\n          attendees: [\n            { ...mockEvent.attendees[0], responseStatus: 'accepted' },\n          ],\n        },\n      });\n\n      await calendarService.respondToEvent({\n        eventId: 'event123',\n        calendarId: 'custom-calendar-id',\n        responseStatus: 'accepted',\n      });\n\n      expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({\n        calendarId: 'custom-calendar-id',\n        eventId: 'event123',\n      });\n\n      expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'custom-calendar-id',\n        }),\n      );\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Calendar API failed');\n      mockCalendarAPI.events.get.mockRejectedValue(apiError);\n\n      const result = await calendarService.respondToEvent({\n        eventId: 'event123',\n        responseStatus: 'accepted',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Calendar API failed');\n    });\n  });\n\n  describe('getEvent', () => {\n    beforeEach(async () => {\n      const mockAuthClient = { access_token: 'test-token' };\n      mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient);\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n    });\n\n    it('should retrieve a specific event', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Test Event',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n\n      const result = await calendarService.getEvent({\n        eventId: 'event123',\n        calendarId: 'primary',\n      });\n\n      expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockEvent);\n    });\n\n    it('should retrieve an event using the primary calendar if no calendarId is provided', async () => {\n      const mockEvent = {\n        id: 'event123',\n        summary: 'Test Event',\n        start: { dateTime: '2024-01-15T10:00:00-07:00' },\n        end: { dateTime: '2024-01-15T11:00:00-07:00' },\n      };\n\n      mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent });\n\n      const result = await calendarService.getEvent({ eventId: 'event123' });\n\n      expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({\n        calendarId: 'primary-calendar-id',\n        eventId: 'event123',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockEvent);\n    });\n\n    it('should handle API errors when getting an event', async () => {\n      const apiError = new Error('Event not found');\n      mockCalendarAPI.events.get.mockRejectedValue(apiError);\n\n      const result = await calendarService.getEvent({\n        eventId: 'non-existent-event',\n        calendarId: 'primary',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Event not found',\n      });\n    });\n  });\n  describe('deleteEvent', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary', primary: true }],\n        },\n      });\n    });\n\n    it('should delete an event from the primary calendar', async () => {\n      mockCalendarAPI.events.delete.mockResolvedValue({});\n\n      const result = await calendarService.deleteEvent({\n        eventId: 'event123',\n      });\n\n      expect(mockCalendarAPI.events.delete).toHaveBeenCalledWith({\n        calendarId: 'primary',\n        eventId: 'event123',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        message: 'Successfully deleted event event123',\n      });\n    });\n\n    it('should delete an event from a specific calendar', async () => {\n      mockCalendarAPI.events.delete.mockResolvedValue({});\n\n      const result = await calendarService.deleteEvent({\n        eventId: 'event123',\n        calendarId: 'work-calendar',\n      });\n\n      expect(mockCalendarAPI.events.delete).toHaveBeenCalledWith({\n        calendarId: 'work-calendar',\n        eventId: 'event123',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        message: 'Successfully deleted event event123',\n      });\n    });\n\n    it('should handle delete errors', async () => {\n      const apiError = new Error('Delete failed');\n      mockCalendarAPI.events.delete.mockRejectedValue(apiError);\n\n      const result = await calendarService.deleteEvent({\n        eventId: 'event123',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Delete failed',\n      });\n    });\n  });\n\n  describe('events with Google Meet and attachments', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary', primary: true }],\n        },\n      });\n    });\n\n    describe('createEvent with Google Meet', () => {\n      it('should create an event with a Google Meet link', async () => {\n        const mockCreatedEvent = {\n          id: 'event123',\n          summary: 'Meeting with Meet',\n          conferenceData: {\n            conferenceId: 'meet-id',\n            entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }],\n          },\n        };\n\n        mockCalendarAPI.events.insert.mockResolvedValue({\n          data: mockCreatedEvent,\n        });\n\n        const result = await calendarService.createEvent({\n          calendarId: 'primary',\n          summary: 'Meeting with Meet',\n          start: { dateTime: '2024-01-15T10:00:00-07:00' },\n          end: { dateTime: '2024-01-15T11:00:00-07:00' },\n          addGoogleMeet: true,\n        });\n\n        expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n          expect.objectContaining({\n            calendarId: 'primary',\n            conferenceDataVersion: 1,\n            requestBody: expect.objectContaining({\n              summary: 'Meeting with Meet',\n              conferenceData: expect.objectContaining({\n                createRequest: expect.objectContaining({\n                  conferenceSolutionKey: { type: 'hangoutsMeet' },\n                }),\n              }),\n            }),\n          }),\n        );\n\n        expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n      });\n\n      it('should not include conferenceData when addGoogleMeet is false', async () => {\n        const mockCreatedEvent = { id: 'event123', summary: 'No Meet' };\n        mockCalendarAPI.events.insert.mockResolvedValue({\n          data: mockCreatedEvent,\n        });\n\n        await calendarService.createEvent({\n          calendarId: 'primary',\n          summary: 'No Meet',\n          start: { dateTime: '2024-01-15T10:00:00-07:00' },\n          end: { dateTime: '2024-01-15T11:00:00-07:00' },\n          addGoogleMeet: false,\n        });\n\n        const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0];\n        expect(callArgs.conferenceDataVersion).toBeUndefined();\n        expect(callArgs.requestBody.conferenceData).toBeUndefined();\n      });\n    });\n\n    describe('createEvent with attachments', () => {\n      it('should create an event with file attachments', async () => {\n        const mockCreatedEvent = {\n          id: 'event123',\n          summary: 'Meeting with Docs',\n          attachments: [\n            {\n              fileUrl: 'https://drive.google.com/open?id=file123',\n              title: 'Agenda',\n            },\n          ],\n        };\n\n        mockCalendarAPI.events.insert.mockResolvedValue({\n          data: mockCreatedEvent,\n        });\n\n        const result = await calendarService.createEvent({\n          calendarId: 'primary',\n          summary: 'Meeting with Docs',\n          start: { dateTime: '2024-01-15T10:00:00-07:00' },\n          end: { dateTime: '2024-01-15T11:00:00-07:00' },\n          attachments: [\n            {\n              fileUrl: 'https://drive.google.com/open?id=file123',\n              title: 'Agenda',\n              mimeType: 'application/vnd.google-apps.document',\n            },\n          ],\n        });\n\n        expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n          expect.objectContaining({\n            supportsAttachments: true,\n            requestBody: expect.objectContaining({\n              attachments: [\n                {\n                  fileUrl: 'https://drive.google.com/open?id=file123',\n                  title: 'Agenda',\n                  mimeType: 'application/vnd.google-apps.document',\n                },\n              ],\n            }),\n          }),\n        );\n\n        expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n      });\n\n      it('should create an event with both Google Meet and attachments', async () => {\n        const mockCreatedEvent = { id: 'event123' };\n        mockCalendarAPI.events.insert.mockResolvedValue({\n          data: mockCreatedEvent,\n        });\n\n        await calendarService.createEvent({\n          calendarId: 'primary',\n          summary: 'Full Featured Meeting',\n          start: { dateTime: '2024-01-15T10:00:00-07:00' },\n          end: { dateTime: '2024-01-15T11:00:00-07:00' },\n          addGoogleMeet: true,\n          attachments: [\n            { fileUrl: 'https://drive.google.com/open?id=file123' },\n          ],\n        });\n\n        const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0];\n        expect(callArgs.conferenceDataVersion).toBe(1);\n        expect(callArgs.supportsAttachments).toBe(true);\n        expect(callArgs.requestBody.conferenceData).toBeDefined();\n        expect(callArgs.requestBody.attachments).toBeDefined();\n      });\n    });\n\n    describe('updateEvent with Google Meet', () => {\n      it('should add Google Meet to an existing event', async () => {\n        const updatedEvent = {\n          id: 'event123',\n          conferenceData: {\n            conferenceId: 'meet-id',\n            entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }],\n          },\n        };\n\n        mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n        const result = await calendarService.updateEvent({\n          eventId: 'event123',\n          addGoogleMeet: true,\n        });\n\n        const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];\n        expect(callArgs.conferenceDataVersion).toBe(1);\n        expect(callArgs.requestBody.conferenceData).toBeDefined();\n        expect(\n          callArgs.requestBody.conferenceData.createRequest\n            .conferenceSolutionKey.type,\n        ).toBe('hangoutsMeet');\n\n        expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent);\n      });\n\n      it('should not include conferenceData when addGoogleMeet is false', async () => {\n        const updatedEvent = { id: 'event123', summary: 'No Meet' };\n        mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n        await calendarService.updateEvent({\n          eventId: 'event123',\n          summary: 'No Meet',\n          addGoogleMeet: false,\n        });\n\n        const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];\n        expect(callArgs.conferenceDataVersion).toBeUndefined();\n        expect(callArgs.requestBody.conferenceData).toBeUndefined();\n      });\n    });\n\n    describe('updateEvent with attachments', () => {\n      it('should add attachments to an existing event', async () => {\n        const updatedEvent = {\n          id: 'event123',\n          attachments: [\n            {\n              fileUrl: 'https://drive.google.com/open?id=file123',\n              title: 'Notes',\n            },\n          ],\n        };\n\n        mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent });\n\n        const result = await calendarService.updateEvent({\n          eventId: 'event123',\n          attachments: [\n            {\n              fileUrl: 'https://drive.google.com/open?id=file123',\n              title: 'Notes',\n            },\n          ],\n        });\n\n        const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0];\n        expect(callArgs.supportsAttachments).toBe(true);\n        expect(callArgs.requestBody.attachments).toEqual([\n          expect.objectContaining({\n            fileUrl: 'https://drive.google.com/open?id=file123',\n            title: 'Notes',\n          }),\n        ]);\n\n        expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent);\n      });\n\n      it('should clear attachments when passed an empty array', async () => {\n        mockCalendarAPI.events.patch.mockResolvedValue({\n          data: { id: 'event123', attachments: [] },\n        });\n\n        await calendarService.updateEvent({\n          eventId: 'event123',\n          attachments: [],\n        });\n\n        expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({\n          calendarId: 'primary',\n          eventId: 'event123',\n          supportsAttachments: true,\n          requestBody: {\n            attachments: [],\n          },\n        });\n      });\n    });\n  });\n\n  describe('updateEvent start/end validation', () => {\n    it('should reject start with both dateTime and date', async () => {\n      const result = await calendarService.updateEvent({\n        eventId: 'event1',\n        start: { dateTime: '2024-01-15T10:00:00Z', date: '2024-01-15' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject end with both dateTime and date', async () => {\n      const result = await calendarService.updateEvent({\n        eventId: 'event1',\n        end: { dateTime: '2024-01-15T12:00:00Z', date: '2024-01-15' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject start with neither dateTime nor date', async () => {\n      const result = await calendarService.updateEvent({\n        eventId: 'event1',\n        start: {},\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n  });\n\n  describe('listEvents with eventTypes', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n    });\n\n    it('should pass eventTypes to the API when provided', async () => {\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: { items: [] },\n      });\n\n      await calendarService.listEvents({\n        calendarId: 'primary',\n        eventTypes: ['focusTime', 'outOfOffice'],\n      });\n\n      expect(mockCalendarAPI.events.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary',\n          eventTypes: ['focusTime', 'outOfOffice'],\n        }),\n      );\n    });\n\n    it('should not pass eventTypes when not provided', async () => {\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: { items: [] },\n      });\n\n      await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const callArgs = mockCalendarAPI.events.list.mock.calls[0][0];\n      expect(callArgs.eventTypes).toBeUndefined();\n    });\n\n    it('should include eventType and status properties in fields', async () => {\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: { items: [] },\n      });\n\n      await calendarService.listEvents({\n        calendarId: 'primary',\n      });\n\n      const callArgs = mockCalendarAPI.events.list.mock.calls[0][0];\n      expect(callArgs.fields).toContain('eventType');\n      expect(callArgs.fields).toContain('focusTimeProperties');\n      expect(callArgs.fields).toContain('outOfOfficeProperties');\n      expect(callArgs.fields).toContain('workingLocationProperties');\n    });\n\n    it('should return focus time events when filtered', async () => {\n      const mockEvents = [\n        {\n          id: 'focus1',\n          summary: 'Focus Time',\n          status: 'confirmed',\n          eventType: 'focusTime',\n          focusTimeProperties: {\n            chatStatus: 'doNotDisturb',\n            autoDeclineMode: 'declineOnlyNewConflictingInvitations',\n          },\n        },\n      ];\n\n      mockCalendarAPI.events.list.mockResolvedValue({\n        data: { items: mockEvents },\n      });\n\n      const result = await calendarService.listEvents({\n        calendarId: 'primary',\n        eventTypes: ['focusTime'],\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult).toHaveLength(1);\n      expect(parsedResult[0].eventType).toBe('focusTime');\n    });\n  });\n\n  describe('createEvent with eventType', () => {\n    beforeEach(async () => {\n      mockCalendarAPI.calendarList.list.mockResolvedValue({\n        data: {\n          items: [{ id: 'primary-calendar-id', primary: true }],\n        },\n      });\n    });\n\n    it('should create a focus time event with defaults', async () => {\n      const mockCreatedEvent = {\n        id: 'focus123',\n        summary: 'Focus Time',\n        eventType: 'focusTime',\n        focusTimeProperties: {\n          chatStatus: 'doNotDisturb',\n          autoDeclineMode: 'declineOnlyNewConflictingInvitations',\n        },\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n        eventType: 'focusTime',\n      });\n\n      const insertArgs = mockCalendarAPI.events.insert.mock.calls[0][0];\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary-calendar-id',\n          requestBody: expect.objectContaining({\n            summary: 'Focus Time',\n            start: { dateTime: '2024-01-15T10:00:00Z' },\n            end: { dateTime: '2024-01-15T12:00:00Z' },\n            eventType: 'focusTime',\n            transparency: 'opaque',\n          }),\n        }),\n      );\n      expect(insertArgs.requestBody?.focusTimeProperties).toEqual({\n        chatStatus: 'doNotDisturb',\n        autoDeclineMode: 'declineOnlyNewConflictingInvitations',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create a focus time event with custom properties', async () => {\n      const mockCreatedEvent = {\n        id: 'focus123',\n        summary: 'Deep Work',\n        eventType: 'focusTime',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        calendarId: 'work-calendar',\n        summary: 'Deep Work',\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n        eventType: 'focusTime',\n        focusTimeProperties: {\n          chatStatus: 'available',\n          autoDeclineMode: 'declineAllConflictingInvitations',\n          declineMessage: 'In focus mode, will respond later',\n        },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'work-calendar',\n          requestBody: expect.objectContaining({\n            summary: 'Deep Work',\n            start: { dateTime: '2024-01-15T10:00:00Z' },\n            end: { dateTime: '2024-01-15T12:00:00Z' },\n            eventType: 'focusTime',\n            transparency: 'opaque',\n            focusTimeProperties: {\n              chatStatus: 'available',\n              autoDeclineMode: 'declineAllConflictingInvitations',\n              declineMessage: 'In focus mode, will respond later',\n            },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create an out-of-office event with defaults', async () => {\n      const mockCreatedEvent = {\n        id: 'ooo123',\n        summary: 'Out of Office',\n        eventType: 'outOfOffice',\n        outOfOfficeProperties: {\n          autoDeclineMode: 'declineOnlyNewConflictingInvitations',\n        },\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        start: { dateTime: '2024-01-15T00:00:00Z' },\n        end: { dateTime: '2024-01-19T00:00:00Z' },\n        eventType: 'outOfOffice',\n      });\n\n      const insertArgs = mockCalendarAPI.events.insert.mock.calls[0][0];\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary-calendar-id',\n          requestBody: expect.objectContaining({\n            summary: 'Out of Office',\n            start: { dateTime: '2024-01-15T00:00:00Z' },\n            end: { dateTime: '2024-01-19T00:00:00Z' },\n            eventType: 'outOfOffice',\n            transparency: 'opaque',\n          }),\n        }),\n      );\n      expect(insertArgs.requestBody?.outOfOfficeProperties).toEqual({\n        autoDeclineMode: 'declineOnlyNewConflictingInvitations',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create an out-of-office event with custom properties', async () => {\n      const mockCreatedEvent = {\n        id: 'ooo123',\n        summary: 'Vacation',\n        eventType: 'outOfOffice',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        calendarId: 'work-calendar',\n        summary: 'Vacation',\n        start: { dateTime: '2024-01-15T00:00:00Z' },\n        end: { dateTime: '2024-01-19T00:00:00Z' },\n        eventType: 'outOfOffice',\n        outOfOfficeProperties: {\n          autoDeclineMode: 'declineAllConflictingInvitations',\n          declineMessage: 'I am on vacation until Jan 19',\n        },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'work-calendar',\n          requestBody: expect.objectContaining({\n            summary: 'Vacation',\n            start: { dateTime: '2024-01-15T00:00:00Z' },\n            end: { dateTime: '2024-01-19T00:00:00Z' },\n            eventType: 'outOfOffice',\n            transparency: 'opaque',\n            outOfOfficeProperties: {\n              autoDeclineMode: 'declineAllConflictingInvitations',\n              declineMessage: 'I am on vacation until Jan 19',\n            },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create a home office working location event', async () => {\n      const mockCreatedEvent = {\n        id: 'wl123',\n        summary: 'Working Location',\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'homeOffice', homeOffice: {} },\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'homeOffice' },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary-calendar-id',\n          requestBody: expect.objectContaining({\n            summary: 'Working Location',\n            start: { date: '2024-01-15' },\n            end: { date: '2024-01-16' },\n            eventType: 'workingLocation',\n            visibility: 'public',\n            transparency: 'transparent',\n            workingLocationProperties: {\n              type: 'homeOffice',\n              homeOffice: {},\n            },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create an office location working location event', async () => {\n      const mockCreatedEvent = {\n        id: 'wl123',\n        summary: 'Working from NYC Office',\n        eventType: 'workingLocation',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        calendarId: 'work-calendar',\n        summary: 'Working from NYC Office',\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: {\n          type: 'officeLocation',\n          officeLocation: {\n            buildingId: 'NYC-1',\n            label: 'New York Office',\n          },\n        },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'work-calendar',\n          requestBody: expect.objectContaining({\n            summary: 'Working from NYC Office',\n            start: { date: '2024-01-15' },\n            end: { date: '2024-01-16' },\n            eventType: 'workingLocation',\n            visibility: 'public',\n            transparency: 'transparent',\n            workingLocationProperties: {\n              type: 'officeLocation',\n              officeLocation: {\n                buildingId: 'NYC-1',\n                label: 'New York Office',\n              },\n            },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create a custom location working location event', async () => {\n      const mockCreatedEvent = {\n        id: 'wl123',\n        summary: 'Working from Coffee Shop',\n        eventType: 'workingLocation',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        summary: 'Working from Coffee Shop',\n        start: { dateTime: '2024-01-15T09:00:00Z' },\n        end: { dateTime: '2024-01-15T17:00:00Z' },\n        eventType: 'workingLocation',\n        workingLocationProperties: {\n          type: 'customLocation',\n          customLocation: { label: 'Downtown Coffee' },\n        },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary-calendar-id',\n          requestBody: expect.objectContaining({\n            summary: 'Working from Coffee Shop',\n            start: { dateTime: '2024-01-15T09:00:00Z' },\n            end: { dateTime: '2024-01-15T17:00:00Z' },\n            eventType: 'workingLocation',\n            visibility: 'public',\n            transparency: 'transparent',\n            workingLocationProperties: {\n              type: 'customLocation',\n              customLocation: { label: 'Downtown Coffee' },\n            },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should reject invalid datetime formats for event types', async () => {\n      const result = await calendarService.createEvent({\n        start: { dateTime: 'not-a-date' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n        eventType: 'focusTime',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should not validate datetime for all-day events', async () => {\n      const mockCreatedEvent = {\n        id: 'wl123',\n        eventType: 'workingLocation',\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'homeOffice' },\n      });\n\n      // Should succeed — no datetime validation for date-only events\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should create an all-day event with date fields', async () => {\n      const mockCreatedEvent = {\n        id: 'allday123',\n        summary: 'Team Offsite',\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-17' },\n      };\n\n      mockCalendarAPI.events.insert.mockResolvedValue({\n        data: mockCreatedEvent,\n      });\n\n      const result = await calendarService.createEvent({\n        summary: 'Team Offsite',\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-17' },\n      });\n\n      expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          calendarId: 'primary-calendar-id',\n          requestBody: expect.objectContaining({\n            summary: 'Team Offsite',\n            start: { date: '2024-01-15' },\n            end: { date: '2024-01-17' },\n          }),\n        }),\n      );\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent);\n    });\n\n    it('should handle API errors gracefully for event types', async () => {\n      const apiError = new Error('Calendar API failed');\n      mockCalendarAPI.events.insert.mockRejectedValue(apiError);\n\n      const result = await calendarService.createEvent({\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n        eventType: 'focusTime',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Calendar API failed',\n      });\n    });\n\n    it('should reject empty start/end objects', async () => {\n      const result = await calendarService.createEvent({\n        summary: 'Bad Event',\n        start: {},\n        end: {},\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject focusTime as all-day event', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'focusTime',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject outOfOffice as all-day event', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'outOfOffice',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject workingLocation without workingLocationProperties', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject all-day workingLocation events that span multiple days', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-17' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'homeOffice' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n      expect(parsedResult.details).toContain(\n        'all-day workingLocation events must span exactly one day',\n      );\n    });\n\n    it('should reject start with both dateTime and date', async () => {\n      const result = await calendarService.createEvent({\n        summary: 'Ambiguous Event',\n        start: { dateTime: '2024-01-15T10:00:00Z', date: '2024-01-15' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject end with both dateTime and date', async () => {\n      const result = await calendarService.createEvent({\n        summary: 'Ambiguous Event',\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z', date: '2024-01-15' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should require summary for regular events', async () => {\n      const result = await calendarService.createEvent({\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should require summary for explicit default eventType', async () => {\n      const result = await calendarService.createEvent({\n        start: { dateTime: '2024-01-15T10:00:00Z' },\n        end: { dateTime: '2024-01-15T12:00:00Z' },\n        eventType: 'default',\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject officeLocation type without officeLocation details', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'officeLocation' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n\n    it('should reject customLocation type without customLocation details', async () => {\n      const result = await calendarService.createEvent({\n        start: { date: '2024-01-15' },\n        end: { date: '2024-01-16' },\n        eventType: 'workingLocation',\n        workingLocationProperties: { type: 'customLocation' },\n      });\n\n      const parsedResult = JSON.parse(result.content[0].text);\n      expect(parsedResult.error).toBe('Invalid input format');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/CalendarValidation.test.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect } from '@jest/globals';\nimport { z } from 'zod';\nimport {\n  validateCreateEventInput,\n  validateUpdateEventInput,\n} from '../../services/CalendarValidation';\n\nfunction getZodIssueMessages(fn: () => void): string[] {\n  try {\n    fn();\n    return [];\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      return error.issues.map((issue) => issue.message);\n    }\n    throw error;\n  }\n}\n\ndescribe('CalendarValidation', () => {\n  describe('validateCreateEventInput', () => {\n    it('accepts a single-day all-day working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-16' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('rejects an all-day working location event that spans multiple days', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-17' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).toThrow('all-day workingLocation events must span exactly one day');\n    });\n\n    it('accepts a leap-day working location event that spans one day', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-02-29' },\n          end: { date: '2024-03-01' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('accepts a year-boundary working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-12-31' },\n          end: { date: '2025-01-01' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('accepts a month-boundary working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-31' },\n          end: { date: '2024-02-01' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('accepts a non-leap Feb working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2023-02-28' },\n          end: { date: '2023-03-01' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('rejects a same-day working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-15' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).toThrow('all-day workingLocation events must span exactly one day');\n    });\n\n    it('rejects a reversed-date working location event', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-16' },\n          end: { date: '2024-01-15' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'homeOffice' },\n        }),\n      ).toThrow('end.date must be on or after start.date');\n    });\n\n    it('rejects a regular event without a summary', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { dateTime: '2024-01-15T10:00:00Z' },\n          end: { dateTime: '2024-01-15T11:00:00Z' },\n        }),\n      ).toThrow('summary is required for regular events');\n    });\n\n    it('rejects focus time events with all-day dates', () => {\n      expect(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-16' },\n          eventType: 'focusTime',\n        }),\n      ).toThrow('focusTime events cannot be all-day events');\n    });\n\n    it('rejects working location officeLocation without office details', () => {\n      const messages = getZodIssueMessages(() =>\n        validateCreateEventInput({\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-16' },\n          eventType: 'workingLocation',\n          workingLocationProperties: { type: 'officeLocation' },\n        }),\n      );\n\n      expect(messages).toContain(\n        'officeLocation is required when workingLocationProperties.type is \"officeLocation\"',\n      );\n    });\n\n    it('rejects invalid attendee emails', () => {\n      expect(() =>\n        validateCreateEventInput({\n          summary: 'Team Meeting',\n          start: { dateTime: '2024-01-15T10:00:00Z' },\n          end: { dateTime: '2024-01-15T11:00:00Z' },\n          attendees: ['not-an-email'],\n        }),\n      ).toThrow('Invalid email format');\n    });\n  });\n\n  describe('validateUpdateEventInput', () => {\n    it('accepts all-day date updates', () => {\n      expect(() =>\n        validateUpdateEventInput({\n          eventId: 'event123',\n          start: { date: '2024-01-15' },\n          end: { date: '2024-01-16' },\n        }),\n      ).not.toThrow();\n    });\n\n    it('rejects empty start objects', () => {\n      const messages = getZodIssueMessages(() =>\n        validateUpdateEventInput({\n          eventId: 'event123',\n          start: {},\n        }),\n      );\n\n      expect(messages).toContain(\n        'start must have exactly one of \"dateTime\" (for timed events) or \"date\" (for all-day events)',\n      );\n    });\n\n    it('rejects invalid dateTime strings', () => {\n      expect(() =>\n        validateUpdateEventInput({\n          eventId: 'event123',\n          start: { dateTime: 'not-a-date' },\n        }),\n      ).toThrow('Invalid ISO 8601 datetime format');\n    });\n\n    it('rejects invalid calendar dates', () => {\n      expect(() =>\n        validateUpdateEventInput({\n          eventId: 'event123',\n          start: { date: '2024-02-30' },\n        }),\n      ).toThrow('Invalid date format. Expected YYYY-MM-DD');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/ChatService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { ChatService } from '../../services/ChatService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('ChatService', () => {\n  let chatService: ChatService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockChatAPI: any;\n  let mockPeopleAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Chat API\n    mockChatAPI = {\n      spaces: {\n        list: jest.fn(),\n        setup: jest.fn(),\n        messages: {\n          create: jest.fn(),\n          list: jest.fn(),\n        },\n        members: {\n          list: jest.fn(),\n        },\n      },\n    };\n\n    // Create mock People API\n    mockPeopleAPI = {\n      people: {\n        get: jest.fn(),\n        searchContacts: jest.fn(),\n      },\n    };\n\n    // Mock the google constructors\n    (google.chat as jest.Mock) = jest.fn().mockReturnValue(mockChatAPI);\n    (google.people as jest.Mock) = jest.fn().mockReturnValue(mockPeopleAPI);\n\n    // Create ChatService instance\n    chatService = new ChatService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('listSpaces', () => {\n    it('should list all chat spaces', async () => {\n      const mockSpaces = [\n        { name: 'spaces/space1', displayName: 'Team Chat' },\n        { name: 'spaces/space2', displayName: 'Project Discussion' },\n      ];\n\n      mockChatAPI.spaces.list.mockResolvedValue({\n        data: {\n          spaces: mockSpaces,\n        },\n      });\n\n      const result = await chatService.listSpaces();\n\n      expect(mockChatAPI.spaces.list).toHaveBeenCalledWith({});\n      expect(JSON.parse(result.content[0].text)).toEqual(mockSpaces);\n    });\n\n    it('should handle empty spaces list', async () => {\n      mockChatAPI.spaces.list.mockResolvedValue({\n        data: {\n          spaces: [],\n        },\n      });\n\n      const result = await chatService.listSpaces();\n\n      expect(JSON.parse(result.content[0].text)).toEqual([]);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Chat API failed');\n      mockChatAPI.spaces.list.mockRejectedValue(apiError);\n\n      const result = await chatService.listSpaces();\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe(\n        'An error occurred while listing chat spaces.',\n      );\n      expect(response.details).toBe('Chat API failed');\n    });\n  });\n\n  describe('sendMessage', () => {\n    it('should send a message to a space', async () => {\n      const mockResponse = {\n        name: 'spaces/space1/messages/msg1',\n        text: 'Hello, team!',\n        createTime: '2024-01-01T00:00:00Z',\n      };\n\n      mockChatAPI.spaces.messages.create.mockResolvedValue({\n        data: mockResponse,\n      });\n\n      const result = await chatService.sendMessage({\n        spaceName: 'spaces/space1',\n        message: 'Hello, team!',\n      });\n\n      expect(mockChatAPI.spaces.messages.create).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        requestBody: {\n          text: 'Hello, team!',\n        },\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual(mockResponse);\n    });\n\n    it('should send a message to a thread in a space', async () => {\n      const mockResponse = {\n        name: 'spaces/space1/messages/msg1',\n        text: 'Hello in thread!',\n        thread: { name: 'spaces/space1/threads/thread1' },\n      };\n\n      mockChatAPI.spaces.messages.create.mockResolvedValue({\n        data: mockResponse,\n      });\n\n      const result = await chatService.sendMessage({\n        spaceName: 'spaces/space1',\n        message: 'Hello in thread!',\n        threadName: 'spaces/space1/threads/thread1',\n      });\n\n      expect(mockChatAPI.spaces.messages.create).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        messageReplyOption: 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD',\n        requestBody: {\n          text: 'Hello in thread!',\n          thread: { name: 'spaces/space1/threads/thread1' },\n        },\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual(mockResponse);\n    });\n\n    it('should handle message sending errors', async () => {\n      const apiError = new Error('Failed to send message');\n      mockChatAPI.spaces.messages.create.mockRejectedValue(apiError);\n\n      const result = await chatService.sendMessage({\n        spaceName: 'spaces/space1',\n        message: 'Test message',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe(\n        'An error occurred while sending the message.',\n      );\n      expect(response.details).toBe('Failed to send message');\n    });\n  });\n\n  describe('findSpaceByName', () => {\n    it('should find spaces by display name', async () => {\n      const mockSpaces = [\n        { name: 'spaces/space1', displayName: 'Team Chat' },\n        { name: 'spaces/space2', displayName: 'Project Discussion' },\n        { name: 'spaces/space3', displayName: 'Team Chat' },\n      ];\n\n      mockChatAPI.spaces.list.mockResolvedValue({\n        data: {\n          spaces: mockSpaces,\n          nextPageToken: null,\n        },\n      });\n\n      const result = await chatService.findSpaceByName({\n        displayName: 'Team Chat',\n      });\n\n      expect(mockChatAPI.spaces.list).toHaveBeenCalled();\n      const foundSpaces = JSON.parse(result.content[0].text);\n      expect(foundSpaces).toHaveLength(2);\n      expect(foundSpaces[0].displayName).toBe('Team Chat');\n      expect(foundSpaces[1].displayName).toBe('Team Chat');\n    });\n\n    it('should handle pagination when searching for spaces', async () => {\n      const mockSpacesPage1 = [\n        { name: 'spaces/space1', displayName: 'Other Chat' },\n        { name: 'spaces/space2', displayName: 'Another Chat' },\n      ];\n      const mockSpacesPage2 = [\n        { name: 'spaces/space3', displayName: 'Team Chat' },\n      ];\n\n      mockChatAPI.spaces.list\n        .mockResolvedValueOnce({\n          data: {\n            spaces: mockSpacesPage1,\n            nextPageToken: 'page2',\n          },\n        })\n        .mockResolvedValueOnce({\n          data: {\n            spaces: mockSpacesPage2,\n            nextPageToken: null,\n          },\n        });\n\n      const result = await chatService.findSpaceByName({\n        displayName: 'Team Chat',\n      });\n\n      expect(mockChatAPI.spaces.list).toHaveBeenCalledTimes(2);\n      const foundSpaces = JSON.parse(result.content[0].text);\n      expect(foundSpaces).toHaveLength(1);\n      expect(foundSpaces[0].displayName).toBe('Team Chat');\n    });\n\n    it('should return error when space not found', async () => {\n      mockChatAPI.spaces.list.mockResolvedValue({\n        data: {\n          spaces: [{ name: 'spaces/space1', displayName: 'Other Chat' }],\n        },\n      });\n\n      const result = await chatService.findSpaceByName({\n        displayName: 'Non-existent Chat',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe(\n        'No space found with display name: Non-existent Chat',\n      );\n    });\n  });\n\n  describe('getMessages', () => {\n    it('should list messages from a space', async () => {\n      const mockMessages = [\n        { name: 'spaces/space1/messages/msg1', text: 'Hello' },\n        { name: 'spaces/space1/messages/msg2', text: 'How are you?' },\n      ];\n\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n          nextPageToken: 'next',\n        },\n      });\n\n      const result = await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        pageSize: 10,\n      });\n\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        pageSize: 10,\n        pageToken: undefined,\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual(mockMessages);\n      expect(response.nextPageToken).toBe('next');\n    });\n\n    it('should filter unread messages when unreadOnly is true', async () => {\n      const mockPerson = {\n        data: {\n          metadata: {\n            sources: [{ type: 'PROFILE', id: 'user123' }],\n          },\n        },\n      };\n\n      const mockMembers = [\n        {\n          member: { name: 'users/user123' },\n          lastReadTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      const mockMessages = [\n        { name: 'spaces/space1/messages/msg1', text: 'Unread message' },\n      ];\n\n      mockPeopleAPI.people.get.mockResolvedValue(mockPerson);\n      mockChatAPI.spaces.members.list.mockResolvedValue({\n        data: {\n          memberships: mockMembers,\n        },\n      });\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n        },\n      });\n\n      const result = await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        unreadOnly: true,\n      });\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/me',\n        personFields: 'metadata',\n      });\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        filter: 'createTime > \"2024-01-01T00:00:00Z\"',\n        pageSize: undefined,\n        pageToken: undefined,\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual(mockMessages);\n    });\n\n    it('should handle case when user has no last read time', async () => {\n      const mockPerson = {\n        data: {\n          metadata: {\n            sources: [{ type: 'PROFILE', id: 'user123' }],\n          },\n        },\n      };\n\n      const mockMembers = [\n        {\n          member: { name: 'users/user123' },\n          // No lastReadTime property\n        },\n      ];\n\n      const mockMessages = [\n        {\n          name: 'spaces/space1/messages/msg1',\n          text: 'All messages are unread',\n        },\n      ];\n\n      mockPeopleAPI.people.get.mockResolvedValue(mockPerson);\n      mockChatAPI.spaces.members.list.mockResolvedValue({\n        data: {\n          memberships: mockMembers,\n        },\n      });\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n        },\n      });\n\n      const result = await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        unreadOnly: true,\n      });\n\n      // Should list all messages when no last read time\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        pageSize: undefined,\n        pageToken: undefined,\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual(mockMessages);\n    });\n\n    it('should pass orderBy to the API', async () => {\n      const mockMessages = [\n        { name: 'spaces/space1/messages/msg1', text: 'Hello' },\n      ];\n\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n        },\n      });\n\n      const result = await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        orderBy: 'createTime desc',\n      });\n\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        pageSize: undefined,\n        pageToken: undefined,\n        orderBy: 'createTime desc',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual(mockMessages);\n    });\n\n    it('should filter messages by threadName', async () => {\n      const mockMessages = [\n        { name: 'spaces/space1/messages/msg1', text: 'Hello' },\n      ];\n\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n        },\n      });\n\n      const threadName = 'spaces/space1/threads/thread1';\n      await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        threadName,\n      });\n\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        filter: `thread.name = \"${threadName}\"`,\n        pageSize: undefined,\n        pageToken: undefined,\n        orderBy: undefined,\n      });\n    });\n\n    it('should combine threadName and unreadOnly filters', async () => {\n      const mockPerson = {\n        data: {\n          metadata: {\n            sources: [{ type: 'PROFILE', id: 'user123' }],\n          },\n        },\n      };\n\n      const mockMembers = [\n        {\n          member: { name: 'users/user123' },\n          lastReadTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      const mockMessages = [\n        {\n          name: 'spaces/space1/messages/msg1',\n          text: 'Unread message in thread',\n        },\n      ];\n\n      mockPeopleAPI.people.get.mockResolvedValue(mockPerson);\n      mockChatAPI.spaces.members.list.mockResolvedValue({\n        data: {\n          memberships: mockMembers,\n        },\n      });\n      mockChatAPI.spaces.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n        },\n      });\n\n      const threadName = 'spaces/space1/threads/thread1';\n      await chatService.getMessages({\n        spaceName: 'spaces/space1',\n        threadName,\n        unreadOnly: true,\n      });\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/me',\n        personFields: 'metadata',\n      });\n      expect(mockChatAPI.spaces.messages.list).toHaveBeenCalledWith({\n        parent: 'spaces/space1',\n        filter: `thread.name = \"${threadName}\" AND createTime > \"2024-01-01T00:00:00Z\"`,\n        pageSize: undefined,\n        pageToken: undefined,\n        orderBy: undefined,\n      });\n    });\n  });\n\n  describe('sendDm', () => {\n    it('should send a direct message to a user', async () => {\n      const mockSpace = {\n        name: 'spaces/dm123',\n        spaceType: 'DIRECT_MESSAGE',\n      };\n\n      const mockMessage = {\n        name: 'spaces/dm123/messages/msg1',\n        text: 'Hello!',\n      };\n\n      mockChatAPI.spaces.setup.mockResolvedValue({\n        data: mockSpace,\n      });\n\n      mockChatAPI.spaces.messages.create.mockResolvedValue({\n        data: mockMessage,\n      });\n\n      const result = await chatService.sendDm({\n        email: 'user@example.com',\n        message: 'Hello!',\n      });\n\n      expect(mockChatAPI.spaces.setup).toHaveBeenCalledWith({\n        requestBody: {\n          space: {\n            spaceType: 'DIRECT_MESSAGE',\n          },\n          memberships: [\n            {\n              member: {\n                name: 'users/user@example.com',\n                type: 'HUMAN',\n              },\n            },\n          ],\n        },\n      });\n\n      expect(mockChatAPI.spaces.messages.create).toHaveBeenCalledWith({\n        parent: 'spaces/dm123',\n        requestBody: {\n          text: 'Hello!',\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toEqual(mockMessage);\n    });\n\n    it('should send a direct message in a thread', async () => {\n      const mockSpace = {\n        name: 'spaces/dm123',\n        spaceType: 'DIRECT_MESSAGE',\n      };\n\n      const mockMessage = {\n        name: 'spaces/dm123/messages/msg1',\n        text: 'Hello again!',\n        thread: { name: 'spaces/dm123/threads/thread1' },\n      };\n\n      mockChatAPI.spaces.setup.mockResolvedValue({\n        data: mockSpace,\n      });\n\n      mockChatAPI.spaces.messages.create.mockResolvedValue({\n        data: mockMessage,\n      });\n\n      const result = await chatService.sendDm({\n        email: 'user@example.com',\n        message: 'Hello again!',\n        threadName: 'spaces/dm123/threads/thread1',\n      });\n\n      expect(mockChatAPI.spaces.messages.create).toHaveBeenCalledWith({\n        parent: 'spaces/dm123',\n        messageReplyOption: 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD',\n        requestBody: {\n          text: 'Hello again!',\n          thread: { name: 'spaces/dm123/threads/thread1' },\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toEqual(mockMessage);\n    });\n\n    it('should handle DM sending errors', async () => {\n      const apiError = new Error('Failed to setup DM space');\n      mockChatAPI.spaces.setup.mockRejectedValue(apiError);\n\n      const result = await chatService.sendDm({\n        email: 'user@example.com',\n        message: 'Test message',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('An error occurred while sending the DM.');\n      expect(response.details).toBe('Failed to setup DM space');\n    });\n  });\n\n  describe('findDmByEmail', () => {\n    it('should find a DM space by user email using spaces.setup', async () => {\n      const mockSpace = {\n        name: 'spaces/dm123',\n        spaceType: 'DIRECT_MESSAGE',\n      };\n\n      mockChatAPI.spaces.setup.mockResolvedValue({\n        data: mockSpace,\n      });\n\n      const result = await chatService.findDmByEmail({\n        email: 'user@example.com',\n      });\n\n      expect(mockChatAPI.spaces.setup).toHaveBeenCalledWith({\n        requestBody: {\n          space: {\n            spaceType: 'DIRECT_MESSAGE',\n          },\n          memberships: [\n            {\n              member: {\n                name: 'users/user@example.com',\n                type: 'HUMAN',\n              },\n            },\n          ],\n        },\n      });\n\n      const foundSpace = JSON.parse(result.content[0].text);\n      expect(foundSpace).toEqual(mockSpace);\n    });\n\n    it('should return an error if spaces.setup fails', async () => {\n      const apiError = new Error('Failed to setup DM space');\n      mockChatAPI.spaces.setup.mockRejectedValue(apiError);\n\n      const result = await chatService.findDmByEmail({\n        email: 'user@example.com',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe(\n        'An error occurred while finding the DM space.',\n      );\n      expect(response.details).toBe('Failed to setup DM space');\n    });\n  });\n\n  describe('createSpace', () => {\n    it('should create a space and return the space data', async () => {\n      const mockResponse = {\n        name: 'spaces/space1',\n        displayName: 'Test Space',\n      };\n\n      mockChatAPI.spaces.setup.mockResolvedValue({\n        data: mockResponse,\n      });\n\n      const result = await chatService.setUpSpace({\n        displayName: 'Test Space',\n        userNames: ['users/123456', 'users/456789'],\n      });\n\n      expect(mockChatAPI.spaces.setup).toHaveBeenCalledWith({\n        requestBody: {\n          space: {\n            spaceType: 'SPACE',\n            displayName: 'Test Space',\n          },\n          memberships: [\n            {\n              member: {\n                name: 'users/123456',\n                type: 'HUMAN',\n              },\n            },\n            {\n              member: {\n                name: 'users/456789',\n                type: 'HUMAN',\n              },\n            },\n          ],\n        },\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual(mockResponse);\n    });\n\n    it('should handle space creation errors', async () => {\n      const apiError = new Error('Failed to create space');\n      mockChatAPI.spaces.setup.mockRejectedValue(apiError);\n\n      const result = await chatService.setUpSpace({\n        displayName: 'Test Space',\n        userNames: ['users/123456'],\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe(\n        'An error occurred while creating the space.',\n      );\n      expect(response.details).toBe('Failed to create space');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/DocsService.comments.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { DocsService } from '../../services/DocsService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('DocsService Comments and Suggestions', () => {\n  let docsService: DocsService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockDocsAPI: any;\n\n  beforeEach(() => {\n    jest.clearAllMocks();\n\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    mockDocsAPI = {\n      documents: {\n        get: jest.fn(),\n        create: jest.fn(),\n        batchUpdate: jest.fn(),\n      },\n    };\n\n    (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI);\n\n    docsService = new DocsService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('getSuggestions', () => {\n    it('should return suggestions as type text with JSON-stringified array', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          title: 'Test Document',\n          body: {\n            content: [\n              {\n                paragraph: {\n                  elements: [\n                    {\n                      textRun: {\n                        content: 'Suggested insertion',\n                        suggestedInsertionIds: ['ins1'],\n                      },\n                      startIndex: 1,\n                      endIndex: 20,\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const parsed = JSON.parse(result.content[0].text);\n      expect(parsed.title).toBe('Test Document');\n      const { suggestions } = parsed;\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0]).toEqual({\n        type: 'insertion',\n        text: 'Suggested insertion',\n        suggestionIds: ['ins1'],\n        startIndex: 1,\n        endIndex: 20,\n      });\n    });\n\n    it('should extract insertion suggestions correctly', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  elements: [\n                    {\n                      textRun: {\n                        content: 'new text',\n                        suggestedInsertionIds: ['sug-1', 'sug-2'],\n                      },\n                      startIndex: 5,\n                      endIndex: 13,\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].type).toBe('insertion');\n      expect(suggestions[0].suggestionIds).toEqual(['sug-1', 'sug-2']);\n    });\n\n    it('should extract deletion suggestions correctly', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  elements: [\n                    {\n                      textRun: {\n                        content: 'deleted text',\n                        suggestedDeletionIds: ['del-1'],\n                      },\n                      startIndex: 1,\n                      endIndex: 13,\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].type).toBe('deletion');\n      expect(suggestions[0].text).toBe('deleted text');\n    });\n\n    it('should extract style change suggestions correctly', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  elements: [\n                    {\n                      textRun: {\n                        content: 'styled text',\n                        suggestedTextStyleChanges: {\n                          'style-1': { textStyle: { bold: true } },\n                        },\n                        textStyle: { bold: true },\n                      },\n                      startIndex: 1,\n                      endIndex: 12,\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].type).toBe('styleChange');\n      expect(suggestions[0].suggestionIds).toEqual(['style-1']);\n      expect(suggestions[0].textStyle).toEqual({ bold: true });\n    });\n\n    it('should extract paragraph style change suggestions', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  paragraphStyle: { namedStyleType: 'HEADING_1' },\n                  suggestedParagraphStyleChanges: {\n                    'sug-para-1': {\n                      paragraphStyle: { namedStyleType: 'HEADING_2' },\n                    },\n                  },\n                  elements: [\n                    {\n                      textRun: { content: 'Heading Text' },\n                      startIndex: 1,\n                      endIndex: 13,\n                    },\n                  ],\n                },\n                startIndex: 1,\n                endIndex: 13,\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].type).toBe('paragraphStyleChange');\n      expect(suggestions[0].suggestionIds).toEqual(['sug-para-1']);\n      expect(suggestions[0].namedStyleType).toBe('HEADING_2');\n      expect(suggestions[0].text).toBe('Heading Text');\n    });\n\n    it('should handle tables with recursive element processing', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                table: {\n                  tableRows: [\n                    {\n                      tableCells: [\n                        {\n                          content: [\n                            {\n                              paragraph: {\n                                elements: [\n                                  {\n                                    textRun: {\n                                      content: 'cell text',\n                                      suggestedInsertionIds: ['cell-ins-1'],\n                                    },\n                                    startIndex: 5,\n                                    endIndex: 14,\n                                  },\n                                ],\n                              },\n                            },\n                          ],\n                        },\n                      ],\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].type).toBe('insertion');\n      expect(suggestions[0].text).toBe('cell text');\n    });\n\n    it('should handle empty document body', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: { body: null },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const parsed = JSON.parse(result.content[0].text);\n      expect(parsed.suggestions).toEqual([]);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDocsAPI.documents.get.mockRejectedValue(new Error('Docs API failed'));\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      expect(result.isError).toBe(true);\n      expect(result.content[0].type).toBe('text');\n      const parsed = JSON.parse(result.content[0].text);\n      expect(parsed).toEqual({ error: 'Docs API failed' });\n    });\n\n    it('should create one suggestion entry per paragraph suggestion ID', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  suggestedParagraphStyleChanges: {\n                    'sug-1': {\n                      paragraphStyle: { namedStyleType: 'HEADING_1' },\n                    },\n                    'sug-2': {\n                      paragraphStyle: { namedStyleType: 'HEADING_2' },\n                    },\n                  },\n                  elements: [\n                    {\n                      textRun: { content: 'Some heading' },\n                      startIndex: 1,\n                      endIndex: 13,\n                    },\n                  ],\n                },\n                startIndex: 1,\n                endIndex: 13,\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(2);\n      const types = suggestions.map((s: any) => s.type);\n      expect(types).toEqual(['paragraphStyleChange', 'paragraphStyleChange']);\n      const ids = suggestions.map((s: any) => s.suggestionIds[0]);\n      expect(ids).toContain('sug-1');\n      expect(ids).toContain('sug-2');\n      const named = suggestions.map((s: any) => s.namedStyleType);\n      expect(named).toContain('HEADING_1');\n      expect(named).toContain('HEADING_2');\n    });\n\n    it('should handle undefined textRun.content with empty string fallback', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          body: {\n            content: [\n              {\n                paragraph: {\n                  elements: [\n                    {\n                      textRun: {\n                        content: undefined,\n                        suggestedInsertionIds: ['ins-undef'],\n                      },\n                      startIndex: 1,\n                      endIndex: 5,\n                    },\n                  ],\n                },\n              },\n            ],\n          },\n        },\n      });\n\n      const result = await docsService.getSuggestions({\n        documentId: 'test-doc-id',\n      });\n\n      const { suggestions } = JSON.parse(result.content[0].text);\n      expect(suggestions).toHaveLength(1);\n      expect(suggestions[0].text).toBe('');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/DocsService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { DocsService, TABS_FIELD_MASK } from '../../services/DocsService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('DocsService', () => {\n  let docsService: DocsService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockDocsAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Docs API\n    mockDocsAPI = {\n      documents: {\n        get: jest.fn(),\n        create: jest.fn(),\n        batchUpdate: jest.fn(),\n      },\n    };\n\n    // Mock the google constructors\n    (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI);\n\n    // Create DocsService instance\n    docsService = new DocsService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('create', () => {\n    it('should create a blank document', async () => {\n      const mockDoc = {\n        data: {\n          documentId: 'test-doc-id',\n          title: 'Test Title',\n        },\n      };\n      mockDocsAPI.documents.create.mockResolvedValue(mockDoc);\n\n      const result = await docsService.create({ title: 'Test Title' });\n\n      expect(mockDocsAPI.documents.create).toHaveBeenCalledWith({\n        requestBody: { title: 'Test Title' },\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        documentId: 'test-doc-id',\n        title: 'Test Title',\n      });\n    });\n\n    it('should create a document with initial content', async () => {\n      const mockDoc = {\n        data: {\n          documentId: 'test-doc-id',\n          title: 'Test Title',\n        },\n      };\n      mockDocsAPI.documents.create.mockResolvedValue(mockDoc);\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      const result = await docsService.create({\n        title: 'Test Title',\n        content: 'Hello World',\n      });\n\n      expect(mockDocsAPI.documents.create).toHaveBeenCalledWith({\n        requestBody: { title: 'Test Title' },\n      });\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              insertText: {\n                location: { index: 1 },\n                text: 'Hello World',\n              },\n            },\n          ],\n        },\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        documentId: 'test-doc-id',\n        title: 'Test Title',\n      });\n    });\n\n    it('should handle errors during document creation', async () => {\n      const apiError = new Error('API Error');\n      mockDocsAPI.documents.create.mockRejectedValue(apiError);\n\n      const result = await docsService.create({ title: 'Test Title' });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('writeText', () => {\n    it('should write text to beginning of document', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      const result = await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: 'Hello',\n        position: 'beginning',\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              insertText: {\n                location: { index: 1, tabId: undefined },\n                text: 'Hello',\n              },\n            },\n          ],\n        },\n      });\n      expect(result.content[0].text).toContain(\n        'Successfully wrote text to document test-doc-id',\n      );\n    });\n\n    it('should write text to end of document (default)', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      const result = await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: ' Appended',\n      });\n\n      // Optimized path: no documents.get call needed\n      expect(mockDocsAPI.documents.get).not.toHaveBeenCalled();\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [{ insertText: { text: ' Appended' } }],\n        },\n      });\n      expect(result.content[0].text).toContain(\n        'Successfully wrote text to document test-doc-id',\n      );\n    });\n\n    it('should write text at a specific numeric index', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: 'Inserted',\n        position: '5',\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              insertText: {\n                location: { index: 5, tabId: undefined },\n                text: 'Inserted',\n              },\n            },\n          ],\n        },\n      });\n    });\n\n    it('should reject invalid position values', async () => {\n      const result = await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: 'Hello',\n        position: 'invalid',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error:\n          'Invalid position: \"invalid\". Use \"beginning\", \"end\", or a positive integer index.',\n      });\n    });\n\n    it('should write text to a specific tab', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: 'Hello',\n        position: 'beginning',\n        tabId: 'tab-1',\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              insertText: {\n                location: {\n                  index: 1,\n                  tabId: 'tab-1',\n                },\n                text: 'Hello',\n              },\n            },\n          ],\n        },\n      });\n    });\n\n    it('should handle errors during writeText', async () => {\n      const apiError = new Error('API Error');\n      mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError);\n\n      const result = await docsService.writeText({\n        documentId: 'test-doc-id',\n        text: 'Hello',\n        position: 'beginning',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('formatText', () => {\n    it('should apply bold and italic text styles', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      const result = await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [\n          { startIndex: 1, endIndex: 10, style: 'bold' },\n          { startIndex: 12, endIndex: 20, style: 'italic' },\n        ],\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              updateTextStyle: {\n                range: { startIndex: 1, endIndex: 10, tabId: undefined },\n                textStyle: { bold: true },\n                fields: 'bold',\n              },\n            },\n            {\n              updateTextStyle: {\n                range: { startIndex: 12, endIndex: 20, tabId: undefined },\n                textStyle: { italic: true },\n                fields: 'italic',\n              },\n            },\n          ],\n        },\n      });\n      expect(result.content[0].text).toContain(\n        'Successfully applied 2 formatting change(s)',\n      );\n    });\n\n    it('should apply heading paragraph styles', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      const result = await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [{ startIndex: 1, endIndex: 15, style: 'heading1' }],\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              updateParagraphStyle: {\n                range: { startIndex: 1, endIndex: 15, tabId: undefined },\n                paragraphStyle: { namedStyleType: 'HEADING_1' },\n                fields: 'namedStyleType',\n              },\n            },\n          ],\n        },\n      });\n      expect(result.content[0].text).toContain(\n        'Successfully applied 1 formatting change(s)',\n      );\n    });\n\n    it('should apply code (monospace) formatting', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [{ startIndex: 5, endIndex: 15, style: 'code' }],\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              updateTextStyle: {\n                range: { startIndex: 5, endIndex: 15, tabId: undefined },\n                textStyle: {\n                  weightedFontFamily: { fontFamily: 'Courier New' },\n                },\n                fields: 'weightedFontFamily',\n              },\n            },\n          ],\n        },\n      });\n    });\n\n    it('should apply link formatting with URL', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [\n          {\n            startIndex: 1,\n            endIndex: 10,\n            style: 'link',\n            url: 'https://example.com',\n          },\n        ],\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              updateTextStyle: {\n                range: { startIndex: 1, endIndex: 10, tabId: undefined },\n                textStyle: { link: { url: 'https://example.com' } },\n                fields: 'link',\n              },\n            },\n          ],\n        },\n      });\n    });\n\n    it('should pass tabId to formatting requests', async () => {\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });\n\n      await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [{ startIndex: 1, endIndex: 5, style: 'bold' }],\n        tabId: 'tab-123',\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            {\n              updateTextStyle: {\n                range: { startIndex: 1, endIndex: 5, tabId: 'tab-123' },\n                textStyle: { bold: true },\n                fields: 'bold',\n              },\n            },\n          ],\n        },\n      });\n    });\n\n    it('should return message for unknown format styles', async () => {\n      const result = await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [{ startIndex: 1, endIndex: 5, style: 'unknownStyle' }],\n      });\n\n      expect(result.content[0].text).toBe(\n        'No valid formatting requests to apply.',\n      );\n      expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled();\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDocsAPI.documents.batchUpdate.mockRejectedValue(\n        new Error('Permission denied'),\n      );\n\n      const result = await docsService.formatText({\n        documentId: 'test-doc-id',\n        formats: [{ startIndex: 1, endIndex: 5, style: 'bold' }],\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Permission denied',\n      });\n    });\n  });\n\n  describe('getText', () => {\n    it('should extract text from a document', async () => {\n      const mockDoc = {\n        data: {\n          tabs: [\n            {\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [\n                          {\n                            textRun: {\n                              content: 'Hello World\\n',\n                            },\n                          },\n                        ],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      };\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({ documentId: 'test-doc-id' });\n\n      expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        fields: `title,${TABS_FIELD_MASK}`,\n        includeTabsContent: true,\n        suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',\n      });\n      expect(result.content[0].text).toBe('Hello World\\n');\n    });\n\n    it('should handle errors during getText', async () => {\n      const apiError = new Error('API Error');\n      mockDocsAPI.documents.get.mockRejectedValue(apiError);\n\n      const result = await docsService.getText({ documentId: 'test-doc-id' });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n\n    it('should extract text from a specific tab', async () => {\n      const mockDoc = {\n        data: {\n          tabs: [\n            {\n              tabProperties: { tabId: 'tab-1', title: 'Tab 1' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Tab 1 Content' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      };\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({\n        documentId: 'test-doc-id',\n        tabId: 'tab-1',\n      });\n\n      expect(result.content[0].text).toBe('Tab 1 Content');\n    });\n\n    it('should return all tabs if no tabId provided and tabs exist', async () => {\n      const mockDoc = {\n        data: {\n          title: 'Multi-Tab Document',\n          tabs: [\n            {\n              tabProperties: { tabId: 'tab-1', title: 'Tab 1' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Tab 1 Content' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n            {\n              tabProperties: { tabId: 'tab-2', title: 'Tab 2' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Tab 2 Content' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      };\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({ documentId: 'test-doc-id' });\n      const parsed = JSON.parse(result.content[0].text);\n\n      expect(parsed.title).toBe('Multi-Tab Document');\n      expect(parsed.tabs).toHaveLength(2);\n      expect(parsed.tabs[0]).toEqual({\n        tabId: 'tab-1',\n        title: 'Tab 1',\n        content: 'Tab 1 Content',\n        index: 0,\n      });\n      expect(parsed.tabs[1]).toEqual({\n        tabId: 'tab-2',\n        title: 'Tab 2',\n        content: 'Tab 2 Content',\n        index: 1,\n      });\n    });\n\n    /** Helper to wrap paragraph elements in the standard mock doc structure. */\n    const mockDocWithElements = (...elements: Record<string, unknown>[]) => ({\n      data: {\n        tabs: [\n          {\n            documentTab: {\n              body: {\n                content: [{ paragraph: { elements } }],\n              },\n            },\n          },\n        ],\n      },\n    });\n\n    it('should extract text from smart chips (date, person, rich link)', async () => {\n      const mockDoc = mockDocWithElements(\n        { textRun: { content: 'Meeting on ' } },\n        {\n          dateElement: {\n            dateElementProperties: {\n              displayText: 'Jan 15, 2025',\n              timestamp: '1736899200',\n            },\n          },\n        },\n        { textRun: { content: ' with ' } },\n        {\n          person: {\n            personProperties: {\n              name: 'John Doe',\n              email: 'john@example.com',\n            },\n          },\n        },\n        { textRun: { content: ' - see ' } },\n        {\n          richLink: {\n            richLinkProperties: {\n              title: 'Project Plan',\n              uri: 'https://docs.google.com/document/d/abc123',\n            },\n          },\n        },\n        { textRun: { content: '\\n' } },\n      );\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({ documentId: 'test-doc-id' });\n\n      expect(result.content[0].text).toBe(\n        'Meeting on Jan 15, 2025 with [John Doe](mailto:john@example.com) - see [Project Plan](https://docs.google.com/document/d/abc123)\\n',\n      );\n    });\n\n    it.each([\n      {\n        name: 'person without name falls back to email',\n        element: {\n          person: {\n            personProperties: {\n              email: 'jane@example.com',\n            },\n          },\n        },\n        expected: '[jane@example.com](mailto:jane@example.com)',\n      },\n      {\n        name: 'person without email falls back to name only',\n        element: {\n          person: {\n            personProperties: {\n              name: 'John Doe',\n            },\n          },\n        },\n        expected: 'John Doe',\n      },\n      {\n        name: 'rich link without title falls back to uri',\n        element: {\n          richLink: {\n            richLinkProperties: {\n              uri: 'https://docs.google.com/spreadsheets/d/xyz',\n            },\n          },\n        },\n        expected:\n          '[https://docs.google.com/spreadsheets/d/xyz](https://docs.google.com/spreadsheets/d/xyz)',\n      },\n      {\n        name: 'rich link without uri falls back to title only',\n        element: {\n          richLink: {\n            richLinkProperties: {\n              title: 'Some Document',\n            },\n          },\n        },\n        expected: 'Some Document',\n      },\n      {\n        name: 'date without displayText falls back to timestamp',\n        element: {\n          dateElement: {\n            dateElementProperties: {\n              timestamp: '1736899200',\n            },\n          },\n        },\n        expected: '1736899200',\n      },\n    ])(\n      'should fall back correctly when $name',\n      async ({ element, expected }) => {\n        mockDocsAPI.documents.get.mockResolvedValue(\n          mockDocWithElements(element),\n        );\n\n        const result = await docsService.getText({\n          documentId: 'test-doc-id',\n        });\n\n        expect(result.content[0].text).toBe(expected);\n      },\n    );\n\n    it('should include text from nested child tabs', async () => {\n      const mockDoc = {\n        data: {\n          title: 'Nested Tabs Doc',\n          tabs: [\n            {\n              tabProperties: { tabId: 'parent-tab', title: 'Parent' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Parent Content' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n              childTabs: [\n                {\n                  tabProperties: { tabId: 'child-tab', title: 'Child' },\n                  documentTab: {\n                    body: {\n                      content: [\n                        {\n                          paragraph: {\n                            elements: [\n                              { textRun: { content: 'Child Content' } },\n                            ],\n                          },\n                        },\n                      ],\n                    },\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      };\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({ documentId: 'test-doc-id' });\n      const parsed = JSON.parse(result.content[0].text);\n\n      expect(parsed.title).toBe('Nested Tabs Doc');\n      expect(parsed.tabs).toHaveLength(2);\n      expect(parsed.tabs[0]).toEqual({\n        tabId: 'parent-tab',\n        title: 'Parent',\n        content: 'Parent Content',\n        index: 0,\n      });\n      expect(parsed.tabs[1]).toEqual({\n        tabId: 'child-tab',\n        title: 'Child',\n        content: 'Child Content',\n        index: 1,\n      });\n    });\n\n    it('should find a child tab by tabId', async () => {\n      const mockDoc = {\n        data: {\n          tabs: [\n            {\n              tabProperties: { tabId: 'parent-tab', title: 'Parent' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Parent Content' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n              childTabs: [\n                {\n                  tabProperties: { tabId: 'child-tab', title: 'Child' },\n                  documentTab: {\n                    body: {\n                      content: [\n                        {\n                          paragraph: {\n                            elements: [\n                              { textRun: { content: 'Child Content' } },\n                            ],\n                          },\n                        },\n                      ],\n                    },\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      };\n      mockDocsAPI.documents.get.mockResolvedValue(mockDoc);\n\n      const result = await docsService.getText({\n        documentId: 'test-doc-id',\n        tabId: 'child-tab',\n      });\n\n      expect(result.content[0].text).toBe('Child Content');\n    });\n  });\n\n  describe('replaceText', () => {\n    it('should replace text in a document', async () => {\n      // Mock the document get call that finds occurrences\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          tabs: [\n            {\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [\n                          { textRun: { content: 'Hello world! Hello again!' } },\n                        ],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      });\n\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({\n        data: {\n          documentId: 'test-doc-id',\n          replies: [],\n        },\n      });\n\n      const result = await docsService.replaceText({\n        documentId: 'test-doc-id',\n        findText: 'Hello',\n        replaceText: 'Hi',\n      });\n\n      expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        fields: TABS_FIELD_MASK,\n        includeTabsContent: true,\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: expect.arrayContaining([\n            expect.objectContaining({\n              deleteContentRange: {\n                range: {\n                  tabId: undefined,\n                  startIndex: 1,\n                  endIndex: 6,\n                },\n              },\n            }),\n            expect.objectContaining({\n              insertText: {\n                location: {\n                  tabId: undefined,\n                  index: 1,\n                },\n                text: 'Hi',\n              },\n            }),\n          ]),\n        },\n      });\n      expect(result.content[0].text).toBe(\n        'Successfully replaced text in document test-doc-id',\n      );\n    });\n\n    it('should replace text with literal content (no markdown parsing)', async () => {\n      // Mock the document get call that finds occurrences\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          tabs: [\n            {\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [\n                          {\n                            textRun: {\n                              content: 'Replace this text and this text too.',\n                            },\n                          },\n                        ],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      });\n\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({\n        data: {\n          documentId: 'test-doc-id',\n          replies: [],\n        },\n      });\n\n      const result = await docsService.replaceText({\n        documentId: 'test-doc-id',\n        findText: 'this text',\n        replaceText: '**bold text**',\n      });\n\n      expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        fields: TABS_FIELD_MASK,\n        includeTabsContent: true,\n      });\n\n      // Text is inserted literally — no markdown parsing\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: [\n            // First occurrence\n            {\n              deleteContentRange: {\n                range: {\n                  tabId: undefined,\n                  startIndex: 9,\n                  endIndex: 18,\n                },\n              },\n            },\n            {\n              insertText: {\n                location: {\n                  tabId: undefined,\n                  index: 9,\n                },\n                text: '**bold text**',\n              },\n            },\n            // Second occurrence (offset by length diff: 13 - 9 = +4)\n            {\n              deleteContentRange: {\n                range: {\n                  tabId: undefined,\n                  startIndex: 27,\n                  endIndex: 36,\n                },\n              },\n            },\n            {\n              insertText: {\n                location: {\n                  tabId: undefined,\n                  index: 27,\n                },\n                text: '**bold text**',\n              },\n            },\n          ],\n        },\n      });\n      expect(result.content[0].text).toBe(\n        'Successfully replaced text in document test-doc-id',\n      );\n    });\n\n    it('should handle errors during replaceText', async () => {\n      // Mock the document get call\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          tabs: [\n            {\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Hello world!' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      });\n\n      const apiError = new Error('API Error');\n      mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError);\n\n      const result = await docsService.replaceText({\n        documentId: 'test-doc-id',\n        findText: 'Hello',\n        replaceText: 'Hi',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n\n    it('should replace text in a specific tab using delete/insert', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          tabs: [\n            {\n              tabProperties: { tabId: 'tab-1' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Hello world!' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n            },\n          ],\n        },\n      });\n\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({\n        data: { documentId: 'test-doc-id' },\n      });\n\n      await docsService.replaceText({\n        documentId: 'test-doc-id',\n        findText: 'Hello',\n        replaceText: 'Hi',\n        tabId: 'tab-1',\n      });\n\n      // Should use deleteContentRange and insertText instead of replaceAllText\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: expect.arrayContaining([\n            expect.objectContaining({\n              deleteContentRange: {\n                range: {\n                  tabId: 'tab-1',\n                  startIndex: 1,\n                  endIndex: 6,\n                },\n              },\n            }),\n            expect.objectContaining({\n              insertText: {\n                location: {\n                  tabId: 'tab-1',\n                  index: 1,\n                },\n                text: 'Hi',\n              },\n            }),\n          ]),\n        },\n      });\n    });\n\n    it('should replace text in a nested child tab by tabId', async () => {\n      mockDocsAPI.documents.get.mockResolvedValue({\n        data: {\n          tabs: [\n            {\n              tabProperties: { tabId: 'parent-tab' },\n              documentTab: {\n                body: {\n                  content: [\n                    {\n                      paragraph: {\n                        elements: [{ textRun: { content: 'Parent text' } }],\n                      },\n                    },\n                  ],\n                },\n              },\n              childTabs: [\n                {\n                  tabProperties: { tabId: 'child-tab' },\n                  documentTab: {\n                    body: {\n                      content: [\n                        {\n                          paragraph: {\n                            elements: [\n                              { textRun: { content: 'Hello child!' } },\n                            ],\n                          },\n                        },\n                      ],\n                    },\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      });\n\n      mockDocsAPI.documents.batchUpdate.mockResolvedValue({\n        data: { documentId: 'test-doc-id' },\n      });\n\n      await docsService.replaceText({\n        documentId: 'test-doc-id',\n        findText: 'Hello',\n        replaceText: 'Hi',\n        tabId: 'child-tab',\n      });\n\n      expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({\n        documentId: 'test-doc-id',\n        requestBody: {\n          requests: expect.arrayContaining([\n            expect.objectContaining({\n              deleteContentRange: {\n                range: {\n                  tabId: 'child-tab',\n                  startIndex: 1,\n                  endIndex: 6,\n                },\n              },\n            }),\n            expect.objectContaining({\n              insertText: {\n                location: {\n                  tabId: 'child-tab',\n                  index: 1,\n                },\n                text: 'Hi',\n              },\n            }),\n          ]),\n        },\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/DriveService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { DriveService } from '../../services/DriveService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\njest.mock('node:fs', () => {\n  const actualFs = jest.requireActual('node:fs') as any;\n  return {\n    ...actualFs,\n    promises: {\n      ...actualFs.promises,\n      mkdir: jest.fn(),\n      writeFile: jest.fn(),\n    },\n    existsSync: jest.fn(),\n    writeFileSync: jest.fn(),\n    mkdirSync: jest.fn(),\n  };\n});\njest.mock('node:path', () => {\n  const actualPath = jest.requireActual('node:path') as any;\n  return {\n    ...actualPath,\n    resolve: jest.fn((...args: string[]) => args.join('/')),\n    dirname: jest.fn((p: string) => p.substring(0, p.lastIndexOf('/'))),\n    isAbsolute: jest.fn((p: string) => p.startsWith('/')),\n  };\n});\njest.mock('../../utils/paths', () => ({\n  PROJECT_ROOT: '/mock/project/root',\n  ENCRYPTED_TOKEN_PATH: '/mock/project/root/token.json',\n  ENCRYPTION_MASTER_KEY_PATH: '/mock/project/root/key',\n}));\n\nimport * as fs from 'node:fs';\n\ndescribe('DriveService', () => {\n  let driveService: DriveService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockDriveAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Drive API\n    mockDriveAPI = {\n      files: {\n        list: jest.fn(),\n        get: jest.fn(),\n        create: jest.fn(),\n        update: jest.fn(),\n        delete: jest.fn(),\n      },\n      comments: {\n        list: jest.fn(),\n      },\n    };\n\n    // Mock the google.drive constructor\n    (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI);\n\n    // Create DriveService instance\n    driveService = new DriveService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('findFolder', () => {\n    it('should find folders by name', async () => {\n      const mockFolders = [\n        { id: 'folder1', name: 'TestFolder' },\n        { id: 'folder2', name: 'TestFolder' },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFolders,\n        },\n      });\n\n      const result = await driveService.findFolder({\n        folderName: 'TestFolder',\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"mimeType='application/vnd.google-apps.folder' and name = 'TestFolder'\",\n        fields: 'files(id, name)',\n        spaces: 'drive',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockFolders);\n    });\n\n    it('should return empty array when no folders found', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: [],\n        },\n      });\n\n      const result = await driveService.findFolder({\n        folderName: 'NonExistentFolder',\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledTimes(1);\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          supportsAllDrives: true,\n          includeItemsFromAllDrives: true,\n        }),\n      );\n      expect(JSON.parse(result.content[0].text)).toEqual([]);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('API request failed');\n      mockDriveAPI.files.list.mockRejectedValue(apiError);\n\n      const result = await driveService.findFolder({\n        folderName: 'TestFolder',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API request failed',\n      });\n    });\n  });\n\n  describe('createFolder', () => {\n    it('should create a folder successfully', async () => {\n      const mockFolder = { id: 'new-folder-id', name: 'New Folder' };\n\n      mockDriveAPI.files.create.mockResolvedValue({\n        data: mockFolder,\n      });\n\n      const result = await driveService.createFolder({ name: 'New Folder' });\n\n      expect(mockDriveAPI.files.create).toHaveBeenCalledWith({\n        requestBody: {\n          name: 'New Folder',\n          mimeType: 'application/vnd.google-apps.folder',\n        },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockFolder);\n    });\n\n    it('should create a folder in a parent folder', async () => {\n      const mockFolder = { id: 'new-folder-id', name: 'New Folder' };\n\n      mockDriveAPI.files.create.mockResolvedValue({\n        data: mockFolder,\n      });\n\n      const result = await driveService.createFolder({\n        name: 'New Folder',\n        parentId: 'parent-id',\n      });\n\n      expect(mockDriveAPI.files.create).toHaveBeenCalledWith({\n        requestBody: {\n          name: 'New Folder',\n          mimeType: 'application/vnd.google-apps.folder',\n          parents: ['parent-id'],\n        },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual(mockFolder);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('API request failed');\n      mockDriveAPI.files.create.mockRejectedValue(apiError);\n\n      const result = await driveService.createFolder({ name: 'New Folder' });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API request failed',\n      });\n    });\n  });\n\n  describe('search', () => {\n    it('should search files with custom query', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: 'Document.pdf',\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n        {\n          id: 'file2',\n          name: 'Spreadsheet.xlsx',\n          modifiedTime: '2024-01-02T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n          nextPageToken: 'next-token',\n        },\n      });\n\n      const result = await driveService.search({\n        query: \"name contains 'Document'\",\n        pageSize: 20,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"name contains 'Document'\",\n        pageSize: 20,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n      expect(responseData.nextPageToken).toBe('next-token');\n    });\n\n    it('should construct query if no field specifier is present', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: 'Document.pdf',\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n        {\n          id: 'file2',\n          name: 'Spreadsheet.xlsx',\n          modifiedTime: '2024-01-02T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n          nextPageToken: 'next-token',\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'My Document',\n        pageSize: 20,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"fullText contains 'My Document'\",\n        pageSize: 20,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n      expect(responseData.nextPageToken).toBe('next-token');\n    });\n\n    it('should escape special characters in search query', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: \"John's Report.pdf\",\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: \"John's \\\\Report\",\n        pageSize: 10,\n      });\n\n      // Verify that single quotes and backslashes are properly escaped\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"fullText contains 'John\\\\'s \\\\\\\\Report'\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should search by title when query starts with title:', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: 'My Document.pdf',\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'title:My Document',\n        pageSize: 10,\n      });\n\n      // Should only search in name field when title: prefix is used\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"name contains 'My Document'\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should handle quoted title searches', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: 'Test Document',\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'title:\"Test Document\"',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"name contains 'Test Document'\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should handle sharedWithMe filter', async () => {\n      const mockFiles = [\n        {\n          id: 'shared1',\n          name: 'SharedDoc.pdf',\n          modifiedTime: '2024-01-01T00:00:00Z',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        sharedWithMe: true,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: 'sharedWithMe',\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should filter unread files when unreadOnly is true', async () => {\n      const mockFiles = [\n        {\n          id: 'file1',\n          name: 'ReadDoc.pdf',\n          viewedByMeTime: '2024-01-01T00:00:00Z',\n        },\n        { id: 'file2', name: 'UnreadDoc.pdf', viewedByMeTime: null },\n        { id: 'file3', name: 'UnreadSpreadsheet.xlsx' }, // No viewedByMeTime property\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'type = \"document\"',\n        unreadOnly: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      // Should only include files without viewedByMeTime\n      expect(responseData.files).toHaveLength(2);\n      expect(responseData.files[0].id).toBe('file2');\n      expect(responseData.files[1].id).toBe('file3');\n    });\n\n    it('should use pagination token', async () => {\n      const mockFiles = [{ id: 'file3', name: 'Page2Doc.pdf' }];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      await driveService.search({\n        query: 'type = \"document\"',\n        pageToken: 'previous-token',\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: 'type = \"document\"',\n        pageSize: 10,\n        pageToken: 'previous-token',\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n    });\n\n    it('should handle corpus parameter', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: [],\n        },\n      });\n\n      await driveService.search({\n        query: 'type = \"document\"',\n        corpus: 'domain',\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: 'type = \"document\"',\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: 'domain',\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Search API failed');\n      mockDriveAPI.files.list.mockRejectedValue(apiError);\n\n      const result = await driveService.search({\n        query: 'type = \"document\"',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Search API failed',\n      });\n    });\n\n    it('should use default values when parameters are not provided', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: [],\n        },\n      });\n\n      await driveService.search({});\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: undefined,\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n    });\n\n    it('should handle Google Drive folder URLs', async () => {\n      const mockFiles = [\n        {\n          id: 'folder123',\n          name: 'My Folder',\n          mimeType: 'application/vnd.google-apps.folder',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'https://drive.google.com/drive/folders/folder123',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"'folder123' in parents\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should handle corporate Google Drive folder URLs', async () => {\n      const mockFiles = [\n        { id: 'file1', name: 'Document.pdf', mimeType: 'application/pdf' },\n        { id: 'file2', name: 'Image.png', mimeType: 'image/png' },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query:\n          'https://drive.google.com/corp/drive/u/0/folders/1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"'1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u' in parents\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should handle Google Drive file URLs', async () => {\n      const mockFile = {\n        id: 'file456',\n        name: 'My Document.pdf',\n        mimeType: 'application/pdf',\n      };\n\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: mockFile,\n      });\n\n      const result = await driveService.search({\n        query: 'https://drive.google.com/file/d/file456/view',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: 'file456',\n        fields: 'id, name, modifiedTime, viewedByMeTime, mimeType, parents',\n        supportsAllDrives: true,\n      });\n      expect(mockDriveAPI.files.list).not.toHaveBeenCalled();\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual([mockFile]);\n      expect(responseData.nextPageToken).toBeNull();\n    });\n\n    it('should handle Google Docs URLs', async () => {\n      const mockFile = {\n        id: 'doc789',\n        name: 'My Document',\n        mimeType: 'application/vnd.google-apps.document',\n      };\n\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: mockFile,\n      });\n\n      const result = await driveService.search({\n        query: 'https://docs.google.com/document/d/doc789/edit',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: 'doc789',\n        fields: 'id, name, modifiedTime, viewedByMeTime, mimeType, parents',\n        supportsAllDrives: true,\n      });\n      expect(mockDriveAPI.files.list).not.toHaveBeenCalled();\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual([mockFile]);\n      expect(responseData.nextPageToken).toBeNull();\n    });\n\n    it('should handle invalid Google Drive URLs', async () => {\n      const result = await driveService.search({\n        query: 'https://drive.google.com/invalid/url',\n        pageSize: 10,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.error).toBe(\n        'Invalid Drive URL. Please provide a valid Google Drive URL or a search query.',\n      );\n      expect(responseData.details).toBe(\n        'Could not extract file or folder ID from the provided URL.',\n      );\n\n      // Should not call the API for invalid URLs\n      expect(mockDriveAPI.files.list).not.toHaveBeenCalled();\n    });\n\n    it('should handle folder URLs with id parameter', async () => {\n      const mockFolder = {\n        id: 'folder789',\n        name: 'My Folder',\n        mimeType: 'application/vnd.google-apps.folder',\n      };\n      const mockFiles = [\n        { id: 'file1', name: 'Document.pdf', mimeType: 'application/pdf' },\n      ];\n\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: mockFolder,\n      });\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: 'https://drive.google.com/drive?id=folder789',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: 'folder789',\n        fields: 'mimeType',\n        supportsAllDrives: true,\n      });\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"'folder789' in parents\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should handle file URLs with id parameter', async () => {\n      const mockFile = {\n        id: 'file123',\n        name: 'My File.pdf',\n        mimeType: 'application/pdf',\n      };\n\n      mockDriveAPI.files.get\n        .mockResolvedValueOnce({\n          data: { mimeType: 'application/pdf' },\n        })\n        .mockResolvedValueOnce({\n          data: mockFile,\n        });\n\n      const result = await driveService.search({\n        query: 'https://drive.google.com/drive?id=file123',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: 'file123',\n        fields: 'mimeType',\n        supportsAllDrives: true,\n      });\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: 'file123',\n        fields: 'id, name, modifiedTime, viewedByMeTime, mimeType, parents',\n        supportsAllDrives: true,\n      });\n      expect(mockDriveAPI.files.list).not.toHaveBeenCalled();\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual([mockFile]);\n    });\n\n    it('should handle raw Drive IDs as folder queries', async () => {\n      const mockFiles = [\n        { id: 'file1', name: 'Document.pdf', mimeType: 'application/pdf' },\n        {\n          id: 'file2',\n          name: 'Spreadsheet.xlsx',\n          mimeType: 'application/vnd.google-apps.spreadsheet',\n        },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: '1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u',\n        pageSize: 10,\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"'1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u' in parents\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n\n    it('should not wrap a valid query in full-text search', async () => {\n      const mockFiles = [\n        { id: 'file1', name: 'My File.pdf', mimeType: 'application/pdf' },\n      ];\n\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: mockFiles,\n        },\n      });\n\n      const result = await driveService.search({\n        query: \"'me' in owners\",\n        pageSize: 10,\n      });\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith({\n        q: \"'me' in owners\",\n        pageSize: 10,\n        pageToken: undefined,\n        corpus: undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const responseData = JSON.parse(result.content[0].text);\n      expect(responseData.files).toEqual(mockFiles);\n    });\n  });\n\n  describe('downloadFile', () => {\n    it('should download files and save locally', async () => {\n      const mockFileId = 'text-file-id';\n      const mockContent = 'Hello, World!';\n      const mockBuffer = Buffer.from(mockContent);\n      const mockLocalPath = 'downloads/test.txt';\n\n      mockDriveAPI.files.get.mockImplementation((params: any) => {\n        if (params.alt === 'media') {\n          return Promise.resolve({\n            data: mockBuffer,\n          });\n        }\n        return Promise.resolve({\n          data: { id: mockFileId, name: 'test.txt', mimeType: 'text/plain' },\n        });\n      });\n\n      const result = await driveService.downloadFile({\n        fileId: mockFileId,\n        localPath: mockLocalPath,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith({\n        fileId: mockFileId,\n        fields: 'id, name, mimeType',\n        supportsAllDrives: true,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith(\n        { fileId: mockFileId, alt: 'media', supportsAllDrives: true },\n        { responseType: 'arraybuffer' },\n      );\n\n      expect(fs.promises.writeFile).toHaveBeenCalledWith(\n        expect.stringContaining(mockLocalPath),\n        mockBuffer,\n      );\n      expect(result.content[0].text).toContain(\n        `Successfully downloaded file test.txt`,\n      );\n    });\n\n    it('should download files when provided with a full Drive URL', async () => {\n      const mockFileId = 'file-id-from-url';\n      const mockUrl = `https://drive.google.com/file/d/${mockFileId}/view`;\n      const mockContent = 'Hello, World!';\n      const mockBuffer = Buffer.from(mockContent);\n      const mockLocalPath = 'downloads/test.txt';\n\n      mockDriveAPI.files.get.mockImplementation((params: any) => {\n        if (params.alt === 'media') {\n          return Promise.resolve({\n            data: mockBuffer,\n          });\n        }\n        return Promise.resolve({\n          data: { id: mockFileId, name: 'test.txt', mimeType: 'text/plain' },\n        });\n      });\n\n      const result = await driveService.downloadFile({\n        fileId: mockUrl,\n        localPath: mockLocalPath,\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: mockFileId,\n          fields: 'id, name, mimeType',\n          supportsAllDrives: true,\n        }),\n      );\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: mockFileId,\n          alt: 'media',\n          supportsAllDrives: true,\n        }),\n        expect.any(Object),\n      );\n\n      expect(result.content[0].text).toContain(\n        `Successfully downloaded file test.txt`,\n      );\n    });\n\n    it('should suggest specialized tools for workspace types', async () => {\n      const mockFileId = 'doc-id';\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: { mimeType: 'application/vnd.google-apps.document' },\n      });\n\n      const result = await driveService.downloadFile({\n        fileId: mockFileId,\n        localPath: 'any',\n      });\n\n      expect(result.content[0].text).toContain(\n        \"This is a Google Doc. Direct download is not supported. Please use the 'docs.getText' tool with documentId: doc-id\",\n      );\n      expect(mockDriveAPI.files.get).toHaveBeenCalledTimes(1);\n    });\n\n    it('should handle API errors', async () => {\n      const mockFileId = 'error-file-id';\n      mockDriveAPI.files.get.mockRejectedValue(new Error('API Error'));\n\n      const result = await driveService.downloadFile({\n        fileId: mockFileId,\n        localPath: 'any',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('trashFile', () => {\n    it('should trash a file by ID', async () => {\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: { id: 'file-id-123', name: 'My File.pdf' },\n      });\n\n      const result = await driveService.trashFile({ fileId: 'file-id-123' });\n\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'file-id-123',\n        requestBody: { trashed: true },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'file-id-123',\n        name: 'My File.pdf',\n        trashed: true,\n      });\n    });\n\n    it('should extract ID from a Drive URL', async () => {\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: { id: 'file-url-id', name: 'URL File.pdf' },\n      });\n\n      const result = await driveService.trashFile({\n        fileId: 'https://drive.google.com/file/d/file-url-id/view',\n      });\n\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'file-url-id',\n        requestBody: { trashed: true },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'file-url-id',\n        name: 'URL File.pdf',\n        trashed: true,\n      });\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDriveAPI.files.update.mockRejectedValue(\n        new Error('Permission denied'),\n      );\n\n      const result = await driveService.trashFile({ fileId: 'file-id-123' });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Permission denied',\n      });\n    });\n  });\n\n  describe('renameFile', () => {\n    it('should rename a file by ID', async () => {\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: { id: 'file-id-123', name: 'New Name' },\n      });\n\n      const result = await driveService.renameFile({\n        fileId: 'file-id-123',\n        newName: 'New Name',\n      });\n\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'file-id-123',\n        requestBody: { name: 'New Name' },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'file-id-123',\n        name: 'New Name',\n      });\n    });\n\n    it('should extract ID from a Drive URL', async () => {\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: { id: 'doc-url-id', name: 'Renamed Doc' },\n      });\n\n      const result = await driveService.renameFile({\n        fileId: 'https://docs.google.com/document/d/doc-url-id/edit',\n        newName: 'Renamed Doc',\n      });\n\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'doc-url-id',\n        requestBody: { name: 'Renamed Doc' },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'doc-url-id',\n        name: 'Renamed Doc',\n      });\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDriveAPI.files.update.mockRejectedValue(new Error('File not found'));\n\n      const result = await driveService.renameFile({\n        fileId: 'file-id-123',\n        newName: 'New Name',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'File not found',\n      });\n    });\n  });\n\n  describe('moveFile', () => {\n    it('should move a file to a folder by folderId', async () => {\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: { parents: ['root'] },\n      });\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: {\n          id: 'test-file-id',\n          name: 'Test File',\n          parents: ['target-folder-id'],\n        },\n      });\n\n      const result = await driveService.moveFile({\n        fileId: 'test-file-id',\n        folderId: 'target-folder-id',\n      });\n\n      expect(mockDriveAPI.files.get).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: 'test-file-id',\n          fields: 'parents',\n          supportsAllDrives: true,\n        }),\n      );\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'test-file-id',\n        addParents: 'target-folder-id',\n        removeParents: 'root',\n        fields: 'id, name, parents',\n        supportsAllDrives: true,\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'test-file-id',\n        name: 'Test File',\n        parents: ['target-folder-id'],\n      });\n    });\n\n    it('should move a file to a folder by folderName', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: {\n          files: [{ id: 'test-folder-id', name: 'Test Folder' }],\n        },\n      });\n      mockDriveAPI.files.get.mockResolvedValue({\n        data: { parents: ['root'] },\n      });\n      mockDriveAPI.files.update.mockResolvedValue({\n        data: {\n          id: 'test-file-id',\n          name: 'Test File',\n          parents: ['test-folder-id'],\n        },\n      });\n\n      const result = await driveService.moveFile({\n        fileId: 'test-file-id',\n        folderName: 'Test Folder',\n      });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          q: \"mimeType='application/vnd.google-apps.folder' and name = 'Test Folder'\",\n        }),\n      );\n      expect(mockDriveAPI.files.update).toHaveBeenCalledWith({\n        fileId: 'test-file-id',\n        addParents: 'test-folder-id',\n        removeParents: 'root',\n        fields: 'id, name, parents',\n        supportsAllDrives: true,\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        id: 'test-file-id',\n        name: 'Test File',\n        parents: ['test-folder-id'],\n      });\n    });\n\n    it('should error when folder not found by name', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({\n        data: { files: [] },\n      });\n\n      const result = await driveService.moveFile({\n        fileId: 'test-file-id',\n        folderName: 'Nonexistent Folder',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Folder not found: Nonexistent Folder',\n      });\n    });\n\n    it('should error when neither folderId nor folderName provided', async () => {\n      const result = await driveService.moveFile({\n        fileId: 'test-file-id',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'Either folderId or folderName must be provided.',\n      });\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDriveAPI.files.get.mockRejectedValue(new Error('API Error'));\n\n      const result = await driveService.moveFile({\n        fileId: 'test-file-id',\n        folderId: 'target-folder-id',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('Shared Drive Support', () => {\n    it('findFolder should include shared drive flags', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({ data: { files: [] } });\n\n      await driveService.findFolder({ folderName: 'SharedFolder' });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          supportsAllDrives: true,\n          includeItemsFromAllDrives: true,\n        }),\n      );\n    });\n\n    it('search should include shared drive flags', async () => {\n      mockDriveAPI.files.list.mockResolvedValue({ data: { files: [] } });\n\n      await driveService.search({ query: 'test' });\n\n      expect(mockDriveAPI.files.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          supportsAllDrives: true,\n          includeItemsFromAllDrives: true,\n        }),\n      );\n    });\n\n    it('createFolder should include supportsAllDrives flag', async () => {\n      mockDriveAPI.files.create.mockResolvedValue({\n        data: { id: 'new-id', name: 'new' },\n      });\n\n      await driveService.createFolder({\n        name: 'New Folder',\n        parentId: 'shared-drive-parent-id',\n      });\n\n      expect(mockDriveAPI.files.create).toHaveBeenCalledWith(\n        expect.objectContaining({\n          supportsAllDrives: true,\n        }),\n      );\n    });\n\n    it('downloadFile should include supportsAllDrives flag for metadata and media', async () => {\n      mockDriveAPI.files.get.mockResolvedValueOnce({\n        data: { mimeType: 'text/plain', name: 'test.txt' },\n      });\n      mockDriveAPI.files.get.mockResolvedValueOnce({\n        data: { data: Buffer.from('content') },\n      });\n\n      await driveService.downloadFile({\n        fileId: 'shared-file-id',\n        localPath: 'test.txt',\n      });\n\n      // First call for metadata\n      expect(mockDriveAPI.files.get).toHaveBeenNthCalledWith(\n        1,\n        expect.objectContaining({\n          supportsAllDrives: true,\n        }),\n      );\n\n      // Second call for media\n      expect(mockDriveAPI.files.get).toHaveBeenNthCalledWith(\n        2,\n        expect.objectContaining({\n          supportsAllDrives: true,\n        }),\n        expect.any(Object),\n      );\n    });\n  });\n\n  describe('getComments', () => {\n    it('should return comments as type text with JSON-stringified array', async () => {\n      const mockComments = [\n        {\n          id: 'comment1',\n          content: 'This is a comment.',\n          author: {\n            displayName: 'Test User',\n            emailAddress: 'test@example.com',\n          },\n          createdTime: '2025-01-01T00:00:00Z',\n          resolved: false,\n          quotedFileContent: { value: 'quoted text' },\n          replies: [],\n        },\n      ];\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: mockComments },\n      });\n\n      const result = await driveService.getComments({\n        fileId: 'test-doc-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const comments = JSON.parse(result.content[0].text);\n      expect(comments).toEqual(mockComments);\n    });\n\n    it('should include replies in comment threads', async () => {\n      const mockComments = [\n        {\n          id: 'comment1',\n          content: 'Top-level comment.',\n          author: { displayName: 'Alice', emailAddress: 'alice@example.com' },\n          createdTime: '2025-01-01T00:00:00Z',\n          resolved: false,\n          quotedFileContent: { value: 'some text' },\n          replies: [\n            {\n              id: 'reply1',\n              content: 'Reply to comment.',\n              author: {\n                displayName: 'Bob',\n                emailAddress: 'bob@example.com',\n              },\n              createdTime: '2025-01-02T00:00:00Z',\n            },\n          ],\n        },\n      ];\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: mockComments },\n      });\n\n      const result = await driveService.getComments({\n        fileId: 'test-doc-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const comments = JSON.parse(result.content[0].text);\n      expect(comments).toHaveLength(1);\n      expect(comments[0].replies).toHaveLength(1);\n      expect(comments[0].replies[0].id).toBe('reply1');\n      expect(comments[0].replies[0].content).toBe('Reply to comment.');\n    });\n\n    it('should request replies fields in the Drive API call', async () => {\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: [] },\n      });\n\n      await driveService.getComments({ fileId: 'test-doc-id' });\n\n      expect(mockDriveAPI.comments.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fields: expect.stringContaining('replies('),\n        }),\n      );\n    });\n\n    it('should handle empty comments list', async () => {\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: [] },\n      });\n\n      const result = await driveService.getComments({\n        fileId: 'test-doc-id',\n      });\n\n      const comments = JSON.parse(result.content[0].text);\n      expect(comments).toEqual([]);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      mockDriveAPI.comments.list.mockRejectedValue(\n        new Error('Comments API failed'),\n      );\n\n      const result = await driveService.getComments({\n        fileId: 'test-doc-id',\n      });\n\n      expect('isError' in result && result.isError).toBe(true);\n      expect(result.content[0].type).toBe('text');\n      const parsed = JSON.parse(result.content[0].text);\n      expect(parsed).toEqual({ error: 'Comments API failed' });\n    });\n\n    it('should return resolved comments with reply actions', async () => {\n      const mockComments = [\n        {\n          id: 'comment1',\n          content: 'Please fix this typo.',\n          author: {\n            displayName: 'Alice',\n            emailAddress: 'alice@example.com',\n          },\n          createdTime: '2025-01-01T00:00:00Z',\n          resolved: true,\n          quotedFileContent: { value: 'teh' },\n          replies: [\n            {\n              id: 'reply1',\n              content: 'Fixed!',\n              author: {\n                displayName: 'Bob',\n                emailAddress: 'bob@example.com',\n              },\n              createdTime: '2025-01-02T00:00:00Z',\n              action: 'resolve',\n            },\n          ],\n        },\n      ];\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: mockComments },\n      });\n\n      const result = await driveService.getComments({\n        fileId: 'test-doc-id',\n      });\n\n      const comments = JSON.parse(result.content[0].text);\n      expect(comments).toHaveLength(1);\n      expect(comments[0].resolved).toBe(true);\n      expect(comments[0].replies[0].action).toBe('resolve');\n    });\n\n    it('should request action field in replies', async () => {\n      mockDriveAPI.comments.list.mockResolvedValue({\n        data: { comments: [] },\n      });\n\n      await driveService.getComments({ fileId: 'test-doc-id' });\n\n      expect(mockDriveAPI.comments.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fields: expect.stringContaining('action'),\n        }),\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/GmailService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport * as fs from 'node:fs/promises';\nimport { GmailService } from '../../services/GmailService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { MimeHelper } from '../../utils/MimeHelper';\nimport { google } from 'googleapis';\n\n// Mock the modules\njest.mock('googleapis');\njest.mock('fs/promises');\njest.mock('../../utils/logger');\njest.mock('../../utils/MimeHelper');\n\ndescribe('GmailService', () => {\n  let gmailService: GmailService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockGmailAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Gmail API\n    mockGmailAPI = {\n      users: {\n        messages: {\n          list: jest.fn(),\n          get: jest.fn(),\n          send: jest.fn(),\n          trash: jest.fn(),\n          untrash: jest.fn(),\n          delete: jest.fn(),\n          modify: jest.fn(),\n          batchModify: jest.fn(),\n          attachments: {\n            get: jest.fn(),\n          },\n        },\n        drafts: {\n          create: jest.fn(),\n          send: jest.fn(),\n          list: jest.fn(),\n          get: jest.fn(),\n          update: jest.fn(),\n          delete: jest.fn(),\n        },\n        labels: {\n          list: jest.fn(),\n          get: jest.fn(),\n          create: jest.fn(),\n          update: jest.fn(),\n          delete: jest.fn(),\n        },\n        threads: {\n          list: jest.fn(),\n          get: jest.fn(),\n          modify: jest.fn(),\n        },\n      },\n    };\n\n    // Mock the google.gmail constructor\n    (google.gmail as jest.Mock) = jest.fn().mockReturnValue(mockGmailAPI);\n\n    // Create GmailService instance\n    gmailService = new GmailService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('search', () => {\n    it('should search for emails with query', async () => {\n      const mockMessages = [\n        { id: 'msg1', threadId: 'thread1' },\n        { id: 'msg2', threadId: 'thread2' },\n      ];\n\n      mockGmailAPI.users.messages.list.mockResolvedValue({\n        data: {\n          messages: mockMessages,\n          nextPageToken: 'next-token',\n          resultSizeEstimate: 100,\n        },\n      });\n\n      const result = await gmailService.search({\n        query: 'from:example@gmail.com',\n        maxResults: 10,\n      });\n\n      expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith({\n        userId: 'me',\n        q: 'from:example@gmail.com',\n        maxResults: 10,\n        pageToken: undefined,\n        labelIds: undefined,\n        includeSpamTrash: false,\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual(mockMessages);\n      expect(response.nextPageToken).toBe('next-token');\n      expect(response.resultSizeEstimate).toBe(100);\n    });\n\n    it('should handle pagination with pageToken', async () => {\n      mockGmailAPI.users.messages.list.mockResolvedValue({\n        data: {\n          messages: [],\n          nextPageToken: null,\n        },\n      });\n\n      await gmailService.search({\n        query: 'subject:Test',\n        pageToken: 'page-2',\n      });\n\n      expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          pageToken: 'page-2',\n        }),\n      );\n    });\n\n    it('should filter by labels', async () => {\n      mockGmailAPI.users.messages.list.mockResolvedValue({\n        data: {\n          messages: [],\n        },\n      });\n\n      await gmailService.search({\n        labelIds: ['INBOX', 'UNREAD'],\n      });\n\n      expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          labelIds: ['INBOX', 'UNREAD'],\n        }),\n      );\n    });\n\n    it('should include spam and trash when specified', async () => {\n      mockGmailAPI.users.messages.list.mockResolvedValue({\n        data: {\n          messages: [],\n        },\n      });\n\n      await gmailService.search({\n        includeSpamTrash: true,\n      });\n\n      expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith(\n        expect.objectContaining({\n          includeSpamTrash: true,\n        }),\n      );\n    });\n\n    it('should handle empty search results', async () => {\n      mockGmailAPI.users.messages.list.mockResolvedValue({\n        data: {\n          messages: null,\n          resultSizeEstimate: 0,\n        },\n      });\n\n      const result = await gmailService.search({});\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.messages).toEqual([]);\n      expect(response.resultSizeEstimate).toBe(0);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      const apiError = new Error('Gmail API error');\n      mockGmailAPI.users.messages.list.mockRejectedValue(apiError);\n\n      const result = await gmailService.search({ query: 'test' });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Gmail API error');\n    });\n  });\n\n  describe('get', () => {\n    it('should get a message by ID with full format', async () => {\n      const mockMessage = {\n        id: 'msg1',\n        threadId: 'thread1',\n        payload: {\n          headers: [\n            { name: 'From', value: 'sender@example.com' },\n            { name: 'To', value: 'recipient@example.com' },\n            { name: 'Subject', value: 'Test Email' },\n          ],\n          body: {\n            data: 'SGVsbG8gV29ybGQh', // Base64 for \"Hello World!\"\n          },\n        },\n      };\n\n      mockGmailAPI.users.messages.get.mockResolvedValue({\n        data: mockMessage,\n      });\n\n      const result = await gmailService.get({\n        messageId: 'msg1',\n        format: 'full',\n      });\n\n      expect(mockGmailAPI.users.messages.get).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        format: 'full',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.id).toBe('msg1');\n      expect(response.subject).toBe('Test Email');\n      expect(response.from).toBe('sender@example.com');\n      expect(response.to).toBe('recipient@example.com');\n      expect(response.attachments).toEqual([]);\n    });\n\n    it('should extract attachments in full format', async () => {\n      const mockMessage = {\n        id: 'msg_with_attach',\n        threadId: 'thread1',\n        payload: {\n          headers: [],\n          filename: '',\n          body: { size: 0 },\n          parts: [\n            {\n              mimeType: 'text/plain',\n              body: { data: 'SGVsbG8=' }, // Hello\n              filename: '',\n            },\n            {\n              mimeType: 'application/pdf',\n              filename: 'test.pdf',\n              body: { attachmentId: 'attach1', size: 1000 },\n            },\n          ],\n        },\n      };\n\n      mockGmailAPI.users.messages.get.mockResolvedValue({\n        data: mockMessage,\n      });\n\n      const result = await gmailService.get({\n        messageId: 'msg_with_attach',\n        format: 'full',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.attachments).toHaveLength(1);\n      expect(response.attachments[0]).toEqual({\n        filename: 'test.pdf',\n        mimeType: 'application/pdf',\n        attachmentId: 'attach1',\n        size: 1000,\n      });\n      expect(response.body).toBe('Hello');\n    });\n\n    it('should handle minimal format', async () => {\n      const mockMessage = {\n        id: 'msg1',\n        threadId: 'thread1',\n        snippet: 'This is a preview of the email...',\n      };\n\n      mockGmailAPI.users.messages.get.mockResolvedValue({\n        data: mockMessage,\n      });\n\n      await gmailService.get({\n        messageId: 'msg1',\n        format: 'minimal',\n      });\n\n      expect(mockGmailAPI.users.messages.get).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        format: 'minimal',\n      });\n    });\n\n    it('should handle metadata format', async () => {\n      mockGmailAPI.users.messages.get.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          payload: {\n            headers: [{ name: 'Subject', value: 'Test' }],\n          },\n        },\n      });\n\n      await gmailService.get({\n        messageId: 'msg1',\n        format: 'metadata',\n      });\n\n      expect(mockGmailAPI.users.messages.get).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        format: 'metadata',\n      });\n    });\n\n    it('should handle API errors', async () => {\n      const apiError = new Error('Message not found');\n      mockGmailAPI.users.messages.get.mockRejectedValue(apiError);\n\n      const result = await gmailService.get({ messageId: 'invalid-id' });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Message not found');\n    });\n  });\n\n  describe('downloadAttachment', () => {\n    it('should download an attachment successfully', async () => {\n      // Setup mocks\n      const mockAttachmentData = {\n        data: 'SGVsbG8gV29ybGQ=', // Base64 for \"Hello World\"\n      };\n      mockGmailAPI.users.messages.attachments.get.mockResolvedValue({\n        data: mockAttachmentData,\n      });\n\n      (fs.mkdir as any).mockResolvedValue('/tmp');\n      (fs.writeFile as any).mockResolvedValue(undefined);\n\n      // Execute\n      const result = await gmailService.downloadAttachment({\n        messageId: 'msg1',\n        attachmentId: 'attach1',\n        localPath: '/tmp/test.txt',\n      });\n\n      // Verify\n      expect(mockGmailAPI.users.messages.attachments.get).toHaveBeenCalledWith({\n        userId: 'me',\n        messageId: 'msg1',\n        id: 'attach1',\n      });\n\n      expect(fs.mkdir).toHaveBeenCalledWith('/tmp', { recursive: true });\n      expect(fs.writeFile).toHaveBeenCalledWith(\n        '/tmp/test.txt',\n        expect.any(Buffer), // We check if it's a buffer, content verification is implicit via Buffer.from logic\n      );\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.message).toContain('Attachment downloaded successfully');\n      expect(response.path).toBe('/tmp/test.txt');\n    });\n\n    it('should reject relative paths', async () => {\n      const result = await gmailService.downloadAttachment({\n        messageId: 'msg1',\n        attachmentId: 'attach1',\n        localPath: 'relative/path.txt',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('localPath must be an absolute path.');\n    });\n\n    it('should handle empty attachment data', async () => {\n      mockGmailAPI.users.messages.attachments.get.mockResolvedValue({\n        data: {}, // No data\n      });\n\n      const result = await gmailService.downloadAttachment({\n        messageId: 'msg1',\n        attachmentId: 'attach1',\n        localPath: '/tmp/test.txt',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Attachment data is empty');\n    });\n\n    it('should handle download errors', async () => {\n      const error = new Error('Download failed');\n      mockGmailAPI.users.messages.attachments.get.mockRejectedValue(error);\n\n      const result = await gmailService.downloadAttachment({\n        messageId: 'msg1',\n        attachmentId: 'attach1',\n        localPath: '/tmp/test.txt',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Download failed');\n    });\n  });\n\n  describe('modify', () => {\n    it('should add a label to a message', async () => {\n      mockGmailAPI.users.messages.modify.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          labelIds: ['Label_1'],\n        },\n      });\n\n      const result = await gmailService.modify({\n        messageId: 'msg1',\n        addLabelIds: ['Label_1'],\n      });\n\n      expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        requestBody: {\n          addLabelIds: ['Label_1'],\n          removeLabelIds: [],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        id: 'msg1',\n        labelIds: ['Label_1'],\n      });\n    });\n\n    it('should add multiple labels to a message', async () => {\n      mockGmailAPI.users.messages.modify.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          labelIds: ['Label_1', 'Label_2'],\n        },\n      });\n\n      const result = await gmailService.modify({\n        messageId: 'msg1',\n        addLabelIds: ['Label_1', 'Label_2'],\n      });\n\n      expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        requestBody: {\n          addLabelIds: ['Label_1', 'Label_2'],\n          removeLabelIds: [],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        id: 'msg1',\n        labelIds: ['Label_1', 'Label_2'],\n      });\n    });\n\n    it('should remove a label from a message', async () => {\n      mockGmailAPI.users.messages.modify.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          labelIds: ['Label_2'],\n        },\n      });\n\n      const result = await gmailService.modify({\n        messageId: 'msg1',\n        removeLabelIds: ['Label_1'],\n      });\n\n      expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        requestBody: {\n          addLabelIds: [],\n          removeLabelIds: ['Label_1'],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        id: 'msg1',\n        labelIds: ['Label_2'],\n      });\n    });\n\n    it('should remove multiple labels from a message', async () => {\n      mockGmailAPI.users.messages.modify.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          labelIds: [],\n        },\n      });\n\n      const result = await gmailService.modify({\n        messageId: 'msg1',\n        removeLabelIds: ['Label_1', 'Label_2'],\n      });\n\n      expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        requestBody: {\n          addLabelIds: [],\n          removeLabelIds: ['Label_1', 'Label_2'],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        id: 'msg1',\n        labelIds: [],\n      });\n    });\n\n    it('should add and remove labels on a message', async () => {\n      mockGmailAPI.users.messages.modify.mockResolvedValue({\n        data: {\n          id: 'msg1',\n          labelIds: ['Label_1'],\n        },\n      });\n\n      const result = await gmailService.modify({\n        messageId: 'msg1',\n        addLabelIds: ['Label_1'],\n        removeLabelIds: ['Label_2'],\n      });\n\n      expect(mockGmailAPI.users.messages.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'msg1',\n        requestBody: {\n          addLabelIds: ['Label_1'],\n          removeLabelIds: ['Label_2'],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        id: 'msg1',\n        labelIds: ['Label_1'],\n      });\n    });\n\n    it('should handle API errors', async () => {\n      const apiError = new Error('Message not found');\n      mockGmailAPI.users.messages.modify.mockRejectedValue(apiError);\n\n      const result = await gmailService.modify({ messageId: 'invalid-id' });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Message not found');\n    });\n  });\n\n  describe('batchModify', () => {\n    it('should batch modify messages with label changes', async () => {\n      mockGmailAPI.users.messages.batchModify.mockResolvedValue({\n        data: undefined,\n      });\n\n      const result = await gmailService.batchModify({\n        messageIds: ['msg1', 'msg2', 'msg3'],\n        addLabelIds: ['Label_1'],\n        removeLabelIds: ['UNREAD'],\n      });\n\n      expect(mockGmailAPI.users.messages.batchModify).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          ids: ['msg1', 'msg2', 'msg3'],\n          addLabelIds: ['Label_1'],\n          removeLabelIds: ['UNREAD'],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toStrictEqual({\n        modifiedCount: 3,\n        addLabelIds: ['Label_1'],\n        removeLabelIds: ['UNREAD'],\n        status: 'success',\n      });\n    });\n\n    it('should return noop when no label changes are provided', async () => {\n      const result = await gmailService.batchModify({\n        messageIds: ['msg1', 'msg2'],\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('noop');\n      expect(response.message).toContain('No labels to add or remove');\n      expect(mockGmailAPI.users.messages.batchModify).not.toHaveBeenCalled();\n    });\n\n    it('should reject when exceeding max message ID limit', async () => {\n      const tooManyIds = Array.from({ length: 1001 }, (_, i) => `msg${i}`);\n\n      const result = await gmailService.batchModify({\n        messageIds: tooManyIds,\n        removeLabelIds: ['UNREAD'],\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toContain('Too many message IDs');\n      expect(response.error).toContain('1000');\n      expect(mockGmailAPI.users.messages.batchModify).not.toHaveBeenCalled();\n    });\n\n    it('should handle API errors', async () => {\n      const apiError = new Error('Batch modify failed');\n      mockGmailAPI.users.messages.batchModify.mockRejectedValue(apiError);\n\n      const result = await gmailService.batchModify({\n        messageIds: ['msg1'],\n        removeLabelIds: ['UNREAD'],\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Batch modify failed');\n    });\n  });\n\n  describe('modifyThread', () => {\n    it('should modify a thread with label changes', async () => {\n      mockGmailAPI.users.threads.modify.mockResolvedValue({\n        data: {\n          id: 'thread1',\n          messages: [\n            { id: 'msg1', labelIds: ['Label_1'] },\n            { id: 'msg2', labelIds: ['Label_1'] },\n          ],\n        },\n      });\n\n      const result = await gmailService.modifyThread({\n        threadId: 'thread1',\n        addLabelIds: ['Label_1'],\n        removeLabelIds: ['UNREAD'],\n      });\n\n      expect(mockGmailAPI.users.threads.modify).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'thread1',\n        requestBody: {\n          addLabelIds: ['Label_1'],\n          removeLabelIds: ['UNREAD'],\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.id).toBe('thread1');\n      expect(response.messages).toHaveLength(2);\n    });\n\n    it('should return noop when no label changes are provided', async () => {\n      const result = await gmailService.modifyThread({\n        threadId: 'thread1',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('noop');\n      expect(response.message).toContain('No labels to add or remove');\n      expect(mockGmailAPI.users.threads.modify).not.toHaveBeenCalled();\n    });\n\n    it('should handle API errors', async () => {\n      const apiError = new Error('Thread not found');\n      mockGmailAPI.users.threads.modify.mockRejectedValue(apiError);\n\n      const result = await gmailService.modifyThread({\n        threadId: 'invalid-thread',\n        removeLabelIds: ['UNREAD'],\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Thread not found');\n    });\n  });\n\n  describe('send', () => {\n    beforeEach(async () => {\n      // Mock MimeHelper\n      (MimeHelper.createMimeMessage as jest.Mock) = jest\n        .fn()\n        .mockReturnValue('base64encodedmessage');\n    });\n\n    it('should send an email with basic parameters', async () => {\n      const mockSentMessage = {\n        id: 'sent-msg-1',\n        threadId: 'thread1',\n        labelIds: ['SENT'],\n      };\n\n      mockGmailAPI.users.messages.send.mockResolvedValue({\n        data: mockSentMessage,\n      });\n\n      const result = await gmailService.send({\n        to: 'recipient@example.com',\n        subject: 'Test Subject',\n        body: 'Test Body',\n      });\n\n      expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith({\n        to: 'recipient@example.com',\n        subject: 'Test Subject',\n        body: 'Test Body',\n        cc: undefined,\n        bcc: undefined,\n        isHtml: false,\n      });\n\n      expect(mockGmailAPI.users.messages.send).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          raw: 'base64encodedmessage',\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('sent');\n      expect(response.id).toBe('sent-msg-1');\n      expect(response.threadId).toBe('thread1');\n      expect(response.labelIds).toEqual(['SENT']);\n    });\n\n    it('should send email with multiple recipients', async () => {\n      mockGmailAPI.users.messages.send.mockResolvedValue({\n        data: { id: 'sent-msg-2' },\n      });\n\n      await gmailService.send({\n        to: ['recipient1@example.com', 'recipient2@example.com'],\n        subject: 'Test',\n        body: 'Body',\n        cc: ['cc1@example.com', 'cc2@example.com'],\n        bcc: 'bcc@example.com',\n      });\n\n      expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith({\n        to: 'recipient1@example.com, recipient2@example.com',\n        subject: 'Test',\n        body: 'Body',\n        cc: 'cc1@example.com, cc2@example.com',\n        bcc: 'bcc@example.com',\n        isHtml: false,\n      });\n    });\n\n    it('should send HTML email', async () => {\n      mockGmailAPI.users.messages.send.mockResolvedValue({\n        data: { id: 'sent-msg-3' },\n      });\n\n      await gmailService.send({\n        to: 'recipient@example.com',\n        subject: 'HTML Test',\n        body: '<h1>Hello</h1>',\n        isHtml: true,\n      });\n\n      expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith(\n        expect.objectContaining({\n          isHtml: true,\n        }),\n      );\n    });\n\n    it('should handle send errors', async () => {\n      const apiError = new Error('Failed to send message');\n      mockGmailAPI.users.messages.send.mockRejectedValue(apiError);\n\n      const result = await gmailService.send({\n        to: 'recipient@example.com',\n        subject: 'Test',\n        body: 'Body',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Failed to send message');\n    });\n  });\n\n  describe('createDraft', () => {\n    beforeEach(async () => {\n      (MimeHelper.createMimeMessage as jest.Mock) = jest\n        .fn()\n        .mockReturnValue('base64encodedmessage');\n    });\n\n    it('should create a draft email', async () => {\n      const mockDraft = {\n        id: 'draft1',\n        message: {\n          id: 'msg1',\n          threadId: 'thread1',\n        },\n      };\n\n      mockGmailAPI.users.drafts.create.mockResolvedValue({\n        data: mockDraft,\n      });\n\n      const result = await gmailService.createDraft({\n        to: 'recipient@example.com',\n        subject: 'Draft Subject',\n        body: 'Draft Body',\n      });\n\n      expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          message: {\n            raw: 'base64encodedmessage',\n          },\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('draft_created');\n      expect(response.id).toBe('draft1');\n      expect(response.message.id).toBe('msg1');\n      expect(response.message.threadId).toBe('thread1');\n    });\n\n    it('should handle draft creation errors', async () => {\n      const apiError = new Error('Failed to create draft');\n      mockGmailAPI.users.drafts.create.mockRejectedValue(apiError);\n\n      const result = await gmailService.createDraft({\n        to: 'recipient@example.com',\n        subject: 'Test',\n        body: 'Body',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Failed to create draft');\n    });\n\n    it('should create a reply draft with threadId', async () => {\n      const mockDraft = {\n        id: 'draft1',\n        message: {\n          id: 'msg1',\n          threadId: 'thread1',\n        },\n      };\n\n      mockGmailAPI.users.threads.get.mockResolvedValue({\n        data: {\n          messages: [\n            {\n              id: 'original-msg',\n              payload: {\n                headers: [\n                  {\n                    name: 'Message-ID',\n                    value: '<original-msg-id@mail.gmail.com>',\n                  },\n                  {\n                    name: 'References',\n                    value: '<earlier-msg-id@mail.gmail.com>',\n                  },\n                ],\n              },\n            },\n          ],\n        },\n      });\n\n      mockGmailAPI.users.drafts.create.mockResolvedValue({\n        data: mockDraft,\n      });\n\n      const result = await gmailService.createDraft({\n        to: 'recipient@example.com',\n        subject: 'Re: Original Subject',\n        body: 'Reply body',\n        threadId: 'thread1',\n      });\n\n      // Verify thread was fetched with both Message-ID and References headers\n      expect(mockGmailAPI.users.threads.get).toHaveBeenCalledWith({\n        userId: 'me',\n        id: 'thread1',\n        format: 'metadata',\n        metadataHeaders: ['Message-ID', 'References'],\n      });\n\n      // Verify References is built by appending Message-ID to existing References\n      expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith(\n        expect.objectContaining({\n          inReplyTo: '<original-msg-id@mail.gmail.com>',\n          references:\n            '<earlier-msg-id@mail.gmail.com> <original-msg-id@mail.gmail.com>',\n        }),\n      );\n\n      // Verify threadId was set on the API request\n      expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          message: {\n            raw: 'base64encodedmessage',\n            threadId: 'thread1',\n          },\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('draft_created');\n      expect(response.id).toBe('draft1');\n    });\n\n    it('should handle thread fetch failure gracefully and still create the draft', async () => {\n      const mockDraft = {\n        id: 'draft2',\n        message: {\n          id: 'msg2',\n          threadId: 'thread1',\n        },\n      };\n\n      mockGmailAPI.users.threads.get.mockRejectedValue(\n        new Error('Thread not found'),\n      );\n\n      mockGmailAPI.users.drafts.create.mockResolvedValue({\n        data: mockDraft,\n      });\n\n      const result = await gmailService.createDraft({\n        to: 'recipient@example.com',\n        subject: 'Re: Original Subject',\n        body: 'Reply body',\n        threadId: 'thread1',\n      });\n\n      // Verify MIME message was created without reply headers\n      expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith(\n        expect.objectContaining({\n          inReplyTo: undefined,\n          references: undefined,\n        }),\n      );\n\n      // Verify threadId was still set on the API request\n      expect(mockGmailAPI.users.drafts.create).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          message: {\n            raw: 'base64encodedmessage',\n            threadId: 'thread1',\n          },\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('draft_created');\n    });\n  });\n\n  describe('sendDraft', () => {\n    it('should send a draft', async () => {\n      const mockSentMessage = {\n        id: 'sent-msg-1',\n        threadId: 'thread1',\n        labelIds: ['SENT'],\n      };\n\n      mockGmailAPI.users.drafts.send.mockResolvedValue({\n        data: mockSentMessage,\n      });\n\n      const result = await gmailService.sendDraft({ draftId: 'draft1' });\n\n      expect(mockGmailAPI.users.drafts.send).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          id: 'draft1',\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.status).toBe('sent');\n      expect(response.id).toBe('sent-msg-1');\n    });\n\n    it('should handle send draft errors', async () => {\n      const apiError = new Error('Draft not found');\n      mockGmailAPI.users.drafts.send.mockRejectedValue(apiError);\n\n      const result = await gmailService.sendDraft({ draftId: 'invalid-draft' });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Draft not found');\n    });\n  });\n\n  describe('listLabels', () => {\n    it('should list all labels', async () => {\n      const mockLabels = [\n        { id: 'INBOX', name: 'INBOX', type: 'system' },\n        { id: 'Label_1', name: 'Work', type: 'user' },\n        { id: 'Label_2', name: 'Personal', type: 'user' },\n      ];\n\n      mockGmailAPI.users.labels.list.mockResolvedValue({\n        data: {\n          labels: mockLabels,\n        },\n      });\n\n      const result = await gmailService.listLabels();\n\n      expect(mockGmailAPI.users.labels.list).toHaveBeenCalledWith({\n        userId: 'me',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.labels).toEqual(mockLabels);\n    });\n\n    it('should handle empty labels list', async () => {\n      mockGmailAPI.users.labels.list.mockResolvedValue({\n        data: {\n          labels: null,\n        },\n      });\n\n      const result = await gmailService.listLabels();\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.labels).toEqual([]);\n    });\n\n    it('should handle list labels errors', async () => {\n      const apiError = new Error('Failed to list labels');\n      mockGmailAPI.users.labels.list.mockRejectedValue(apiError);\n\n      const result = await gmailService.listLabels();\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Failed to list labels');\n    });\n  });\n\n  describe('createLabel', () => {\n    it('should create a label with default visibility', async () => {\n      const mockLabel = {\n        id: 'Label_1',\n        name: 'Test Label',\n        type: 'user',\n        labelListVisibility: 'labelShow',\n        messageListVisibility: 'show',\n      };\n\n      mockGmailAPI.users.labels.create.mockResolvedValue({\n        data: mockLabel,\n      });\n\n      const result = await gmailService.createLabel({\n        name: 'Test Label',\n      });\n\n      expect(mockGmailAPI.users.labels.create).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          name: 'Test Label',\n          labelListVisibility: 'labelShow',\n          messageListVisibility: 'show',\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toEqual({\n        ...mockLabel,\n        status: 'created',\n      });\n    });\n\n    it('should create a label with custom visibility settings', async () => {\n      const mockLabel = {\n        id: 'Label_2',\n        name: 'Hidden Label',\n        type: 'user',\n        labelListVisibility: 'labelHide',\n        messageListVisibility: 'hide',\n      };\n\n      mockGmailAPI.users.labels.create.mockResolvedValue({\n        data: mockLabel,\n      });\n\n      const result = await gmailService.createLabel({\n        name: 'Hidden Label',\n        labelListVisibility: 'labelHide',\n        messageListVisibility: 'hide',\n      });\n\n      expect(mockGmailAPI.users.labels.create).toHaveBeenCalledWith({\n        userId: 'me',\n        requestBody: {\n          name: 'Hidden Label',\n          labelListVisibility: 'labelHide',\n          messageListVisibility: 'hide',\n        },\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response).toEqual({\n        ...mockLabel,\n        status: 'created',\n      });\n    });\n\n    it('should handle create label errors', async () => {\n      const apiError = new Error('Label already exists');\n      mockGmailAPI.users.labels.create.mockRejectedValue(apiError);\n\n      const result = await gmailService.createLabel({\n        name: 'Duplicate Label',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Label already exists');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/PeopleService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { PeopleService } from '../../services/PeopleService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('PeopleService', () => {\n  let peopleService: PeopleService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockPeopleAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock People API\n    mockPeopleAPI = {\n      people: {\n        get: jest.fn(),\n        searchDirectoryPeople: jest.fn(),\n      },\n    };\n\n    // Mock the google constructors\n    (google.people as jest.Mock) = jest.fn().mockReturnValue(mockPeopleAPI);\n\n    // Create PeopleService instance\n    peopleService = new PeopleService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('getUserProfile', () => {\n    it('should return a user profile by userId', async () => {\n      const mockUser = {\n        data: {\n          resourceName: 'people/110001608645105799644',\n          names: [\n            {\n              displayName: 'Test User',\n            },\n          ],\n          emailAddresses: [\n            {\n              value: 'test@example.com',\n            },\n          ],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockUser);\n\n      const result = await peopleService.getUserProfile({\n        userId: '110001608645105799644',\n      });\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/110001608645105799644',\n        personFields: 'names,emailAddresses',\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        results: [{ person: mockUser.data }],\n      });\n    });\n\n    it('should return a user profile by email', async () => {\n      const mockUser = {\n        data: {\n          results: [\n            {\n              person: {\n                resourceName: 'people/110001608645105799644',\n                names: [\n                  {\n                    displayName: 'Test User',\n                  },\n                ],\n                emailAddresses: [\n                  {\n                    value: 'test@example.com',\n                  },\n                ],\n              },\n            },\n          ],\n        },\n      };\n      mockPeopleAPI.people.searchDirectoryPeople.mockResolvedValue(mockUser);\n\n      const result = await peopleService.getUserProfile({\n        email: 'test@example.com',\n      });\n\n      expect(mockPeopleAPI.people.searchDirectoryPeople).toHaveBeenCalledWith({\n        query: 'test@example.com',\n        readMask: 'names,emailAddresses',\n        sources: [\n          'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',\n          'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE',\n        ],\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual(mockUser.data);\n    });\n\n    it('should handle errors during getUserProfile', async () => {\n      const apiError = new Error('API Error');\n      mockPeopleAPI.people.get.mockRejectedValue(apiError);\n\n      const result = await peopleService.getUserProfile({\n        userId: '110001608645105799644',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('getMe', () => {\n    it(\"should return the authenticated user's profile\", async () => {\n      const mockMe = {\n        data: {\n          resourceName: 'people/me',\n          names: [\n            {\n              displayName: 'Me',\n            },\n          ],\n          emailAddresses: [\n            {\n              value: 'me@example.com',\n            },\n          ],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockMe);\n\n      const result = await peopleService.getMe();\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/me',\n        personFields: 'names,emailAddresses',\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual(mockMe.data);\n    });\n\n    it('should handle errors during getMe', async () => {\n      const apiError = new Error('API Error');\n      mockPeopleAPI.people.get.mockRejectedValue(apiError);\n\n      const result = await peopleService.getMe();\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n  });\n\n  describe('getUserRelations', () => {\n    it('should return all relations when no relationType is specified', async () => {\n      const mockRelations = {\n        data: {\n          relations: [\n            { person: 'John Doe', type: 'manager' },\n            { person: 'Jane Doe', type: 'spouse' },\n            { person: 'Bob Smith', type: 'assistant' },\n          ],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      const result = await peopleService.getUserRelations({});\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/me',\n        personFields: 'relations',\n      });\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        resourceName: 'people/me',\n        relations: mockRelations.data.relations,\n      });\n    });\n\n    it('should filter relations by relationType when specified', async () => {\n      const mockRelations = {\n        data: {\n          relations: [\n            { person: 'John Doe', type: 'manager' },\n            { person: 'Jane Doe', type: 'spouse' },\n            { person: 'Bob Smith', type: 'assistant' },\n          ],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      const result = await peopleService.getUserRelations({\n        relationType: 'manager',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        resourceName: 'people/me',\n        relationType: 'manager',\n        relations: [{ person: 'John Doe', type: 'manager' }],\n      });\n    });\n\n    it('should filter relations case-insensitively', async () => {\n      const mockRelations = {\n        data: {\n          relations: [{ person: 'John Doe', type: 'Manager' }],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      const result = await peopleService.getUserRelations({\n        relationType: 'MANAGER',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        resourceName: 'people/me',\n        relationType: 'MANAGER',\n        relations: [{ person: 'John Doe', type: 'Manager' }],\n      });\n    });\n\n    it('should return empty relations array when no relations exist', async () => {\n      const mockRelations = {\n        data: {},\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      const result = await peopleService.getUserRelations({});\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        resourceName: 'people/me',\n        relations: [],\n      });\n    });\n\n    it('should return empty array when filtering for non-existent relationType', async () => {\n      const mockRelations = {\n        data: {\n          relations: [{ person: 'John Doe', type: 'manager' }],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      const result = await peopleService.getUserRelations({\n        relationType: 'spouse',\n      });\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        resourceName: 'people/me',\n        relationType: 'spouse',\n        relations: [],\n      });\n    });\n\n    it('should handle errors during getUserRelations', async () => {\n      const apiError = new Error('API Error');\n      mockPeopleAPI.people.get.mockRejectedValue(apiError);\n\n      const result = await peopleService.getUserRelations({});\n\n      expect(JSON.parse(result.content[0].text)).toEqual({\n        error: 'API Error',\n      });\n    });\n\n    it('should call with the correct resourceName when a userId is provided', async () => {\n      const mockRelations = {\n        data: {\n          relations: [{ person: 'John Doe', type: 'manager' }],\n        },\n      };\n      mockPeopleAPI.people.get.mockResolvedValue(mockRelations);\n\n      await peopleService.getUserRelations({ userId: '110001608645105799644' });\n\n      expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({\n        resourceName: 'people/110001608645105799644',\n        personFields: 'relations',\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/SheetsService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { SheetsService } from '../../services/SheetsService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\n\ndescribe('SheetsService', () => {\n  let sheetsService: SheetsService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockSheetsAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Sheets API\n    mockSheetsAPI = {\n      spreadsheets: {\n        get: jest.fn(),\n        values: {\n          get: jest.fn(),\n        },\n      },\n    };\n\n    // Mock the google constructors\n    (google.sheets as jest.Mock) = jest.fn().mockReturnValue(mockSheetsAPI);\n\n    // Create SheetsService instance\n    sheetsService = new SheetsService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('getText', () => {\n    it('should extract text from a spreadsheet in default format', async () => {\n      const mockSpreadsheet = {\n        data: {\n          properties: {\n            title: 'Test Spreadsheet',\n          },\n          sheets: [\n            { properties: { title: 'Sheet1' } },\n            { properties: { title: 'Sheet2' } },\n          ],\n        },\n      };\n\n      const mockSheet1Data = {\n        data: {\n          values: [\n            ['Header1', 'Header2', 'Header3'],\n            ['Row1Col1', 'Row1Col2', 'Row1Col3'],\n            ['Row2Col1', 'Row2Col2', 'Row2Col3'],\n          ],\n        },\n      };\n\n      const mockSheet2Data = {\n        data: {\n          values: [\n            ['A', 'B'],\n            ['1', '2'],\n          ],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet);\n      mockSheetsAPI.spreadsheets.values.get\n        .mockResolvedValueOnce(mockSheet1Data)\n        .mockResolvedValueOnce(mockSheet2Data);\n\n      const result = await sheetsService.getText({\n        spreadsheetId: 'test-spreadsheet-id',\n      });\n\n      expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({\n        spreadsheetId: 'test-spreadsheet-id',\n        includeGridData: false,\n      });\n\n      expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledTimes(2);\n      expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(1, {\n        spreadsheetId: 'test-spreadsheet-id',\n        range: \"'Sheet1'\",\n      });\n      expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(2, {\n        spreadsheetId: 'test-spreadsheet-id',\n        range: \"'Sheet2'\",\n      });\n\n      expect(result.content[0].type).toBe('text');\n      expect(result.content[0].text).toContain('Test Spreadsheet');\n      expect(result.content[0].text).toContain('Sheet1');\n      expect(result.content[0].text).toContain('Header1 | Header2 | Header3');\n      expect(result.content[0].text).toContain('Sheet2');\n      expect(result.content[0].text).toContain('A | B');\n    });\n\n    it('should extract text in CSV format', async () => {\n      const mockSpreadsheet = {\n        data: {\n          properties: {\n            title: 'CSV Test',\n          },\n          sheets: [{ properties: { title: 'Sheet1' } }],\n        },\n      };\n\n      const mockSheetData = {\n        data: {\n          values: [\n            ['Name', 'Age', 'City'],\n            ['John, Jr.', '25', 'New York'],\n            ['Jane', '30', 'San Francisco'],\n          ],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet);\n      mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData);\n\n      const result = await sheetsService.getText({\n        spreadsheetId: 'test-spreadsheet-id',\n        format: 'csv',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      expect(result.content[0].text).toContain('Name,Age,City');\n      expect(result.content[0].text).toContain('\"John, Jr.\",25,New York');\n    });\n\n    it('should extract text in JSON format', async () => {\n      const mockSpreadsheet = {\n        data: {\n          properties: {\n            title: 'JSON Test',\n          },\n          sheets: [{ properties: { title: 'Sheet1' } }],\n        },\n      };\n\n      const mockSheetData = {\n        data: {\n          values: [\n            ['A', 'B'],\n            ['1', '2'],\n          ],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet);\n      mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData);\n\n      const result = await sheetsService.getText({\n        spreadsheetId: 'test-spreadsheet-id',\n        format: 'json',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const jsonResult = JSON.parse(result.content[0].text);\n      expect(jsonResult.Sheet1).toEqual([\n        ['A', 'B'],\n        ['1', '2'],\n      ]);\n    });\n\n    it('should handle empty sheets', async () => {\n      const mockSpreadsheet = {\n        data: {\n          properties: {\n            title: 'Empty Test',\n          },\n          sheets: [{ properties: { title: 'EmptySheet' } }],\n        },\n      };\n\n      const mockSheetData = {\n        data: {\n          values: [],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet);\n      mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData);\n\n      const result = await sheetsService.getText({\n        spreadsheetId: 'test-spreadsheet-id',\n      });\n\n      expect(result.content[0].text).toContain('EmptySheet');\n      expect(result.content[0].text).toContain('(Empty sheet)');\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSheetsAPI.spreadsheets.get.mockRejectedValue(new Error('API Error'));\n\n      const result = await sheetsService.getText({\n        spreadsheetId: 'error-spreadsheet-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('API Error');\n    });\n  });\n\n  describe('getRange', () => {\n    it('should get values from a specific range', async () => {\n      const mockRangeData = {\n        data: {\n          range: 'Sheet1!A1:B3',\n          values: [\n            ['A1', 'B1'],\n            ['A2', 'B2'],\n            ['A3', 'B3'],\n          ],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData);\n\n      const result = await sheetsService.getRange({\n        spreadsheetId: 'test-spreadsheet-id',\n        range: 'Sheet1!A1:B3',\n      });\n\n      expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledWith({\n        spreadsheetId: 'test-spreadsheet-id',\n        range: 'Sheet1!A1:B3',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.range).toBe('Sheet1!A1:B3');\n      expect(response.values).toHaveLength(3);\n      expect(response.values[0]).toEqual(['A1', 'B1']);\n    });\n\n    it('should handle empty ranges', async () => {\n      const mockRangeData = {\n        data: {\n          range: 'Sheet1!Z100:Z200',\n          values: [],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData);\n\n      const result = await sheetsService.getRange({\n        spreadsheetId: 'test-spreadsheet-id',\n        range: 'Sheet1!Z100:Z200',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.values).toEqual([]);\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSheetsAPI.spreadsheets.values.get.mockRejectedValue(\n        new Error('Range Error'),\n      );\n\n      const result = await sheetsService.getRange({\n        spreadsheetId: 'error-id',\n        range: 'InvalidRange',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('Range Error');\n    });\n  });\n\n  describe('getMetadata', () => {\n    it('should retrieve spreadsheet metadata', async () => {\n      const mockSpreadsheet = {\n        data: {\n          spreadsheetId: 'test-id',\n          properties: {\n            title: 'Test Spreadsheet',\n            locale: 'en_US',\n            timeZone: 'America/New_York',\n          },\n          sheets: [\n            {\n              properties: {\n                sheetId: 0,\n                title: 'Sheet1',\n                index: 0,\n                gridProperties: {\n                  rowCount: 1000,\n                  columnCount: 26,\n                },\n              },\n            },\n            {\n              properties: {\n                sheetId: 1,\n                title: 'Sheet2',\n                index: 1,\n                gridProperties: {\n                  rowCount: 500,\n                  columnCount: 10,\n                },\n              },\n            },\n          ],\n        },\n      };\n\n      mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet);\n\n      const result = await sheetsService.getMetadata({\n        spreadsheetId: 'test-id',\n      });\n      const metadata = JSON.parse(result.content[0].text);\n\n      expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({\n        spreadsheetId: 'test-id',\n        includeGridData: false,\n      });\n\n      expect(metadata.spreadsheetId).toBe('test-id');\n      expect(metadata.title).toBe('Test Spreadsheet');\n      expect(metadata.locale).toBe('en_US');\n      expect(metadata.timeZone).toBe('America/New_York');\n      expect(metadata.sheets).toHaveLength(2);\n      expect(metadata.sheets[0].title).toBe('Sheet1');\n      expect(metadata.sheets[0].rowCount).toBe(1000);\n      expect(metadata.sheets[0].columnCount).toBe(26);\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSheetsAPI.spreadsheets.get.mockRejectedValue(\n        new Error('Metadata Error'),\n      );\n\n      const result = await sheetsService.getMetadata({\n        spreadsheetId: 'error-id',\n      });\n      const response = JSON.parse(result.content[0].text);\n\n      expect(response.error).toBe('Metadata Error');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/SlidesService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport { SlidesService } from '../../services/SlidesService';\nimport { AuthManager } from '../../auth/AuthManager';\nimport { google } from 'googleapis';\nimport { request } from 'gaxios';\nimport * as fs from 'node:fs/promises';\n\n// Mock the googleapis module\njest.mock('googleapis');\njest.mock('../../utils/logger');\njest.mock('gaxios');\njest.mock('node:fs/promises');\njest.mock('node:path', () => {\n  const actualPath = jest.requireActual('node:path') as any;\n  return {\n    ...actualPath,\n    join: jest.fn((...args: string[]) =>\n      args.join('/').replace(/\\\\/g, '/').replace(/\\/+/g, '/'),\n    ),\n    dirname: jest.fn((p: string) => {\n      const normalized = p.replace(/\\\\/g, '/');\n      return normalized.substring(0, normalized.lastIndexOf('/'));\n    }),\n    isAbsolute: jest.fn(\n      (p: string) => p.startsWith('/') || /^[a-zA-Z]:/.test(p),\n    ),\n  };\n});\n\ndescribe('SlidesService', () => {\n  let slidesService: SlidesService;\n  let mockAuthManager: jest.Mocked<AuthManager>;\n  let mockSlidesAPI: any;\n\n  beforeEach(() => {\n    // Clear all mocks before each test\n    jest.clearAllMocks();\n\n    // Create mock AuthManager\n    mockAuthManager = {\n      getAuthenticatedClient: jest.fn(),\n    } as any;\n\n    // Create mock Slides API\n    mockSlidesAPI = {\n      presentations: {\n        get: jest.fn(),\n      },\n    };\n\n    // Mock the google constructors\n    (google.slides as jest.Mock) = jest.fn().mockReturnValue(mockSlidesAPI);\n\n    // Create SlidesService instance\n    slidesService = new SlidesService(mockAuthManager);\n\n    const mockAuthClient = { access_token: 'test-token' };\n    mockAuthManager.getAuthenticatedClient.mockResolvedValue(\n      mockAuthClient as any,\n    );\n\n    // Default mocks for downloads\n    (request as any).mockResolvedValue({\n      data: Buffer.from('test-data'),\n    });\n    (fs.mkdir as any).mockResolvedValue(undefined);\n    (fs.writeFile as any).mockResolvedValue(undefined);\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('getText', () => {\n    it('should extract text from a presentation', async () => {\n      const mockPresentation = {\n        data: {\n          title: 'Test Presentation',\n          slides: [\n            {\n              pageElements: [\n                {\n                  shape: {\n                    text: {\n                      textElements: [\n                        { textRun: { content: 'Slide 1 Title' } },\n                        { paragraphMarker: {} },\n                        { textRun: { content: 'Slide 1 Content' } },\n                      ],\n                    },\n                  },\n                },\n              ],\n            },\n            {\n              pageElements: [\n                {\n                  table: {\n                    tableRows: [\n                      {\n                        tableCells: [\n                          {\n                            text: {\n                              textElements: [\n                                { textRun: { content: 'Cell 1' } },\n                              ],\n                            },\n                          },\n                          {\n                            text: {\n                              textElements: [\n                                { textRun: { content: 'Cell 2' } },\n                              ],\n                            },\n                          },\n                        ],\n                      },\n                    ],\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      };\n\n      mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);\n\n      const result = await slidesService.getText({\n        presentationId: 'test-presentation-id',\n      });\n\n      expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({\n        presentationId: 'test-presentation-id',\n        fields:\n          'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      expect(result.content[0].text).toContain('Test Presentation');\n      expect(result.content[0].text).toContain('Slide 1 Title');\n      expect(result.content[0].text).toContain('Slide 1 Content');\n      expect(result.content[0].text).toContain('Cell 1 | Cell 2');\n    });\n\n    it('should handle presentations with no slides', async () => {\n      const mockPresentation = {\n        data: {\n          title: 'Empty Presentation',\n          slides: [],\n        },\n      };\n\n      mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);\n\n      const result = await slidesService.getText({\n        presentationId: 'empty-presentation-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      expect(result.content[0].text).toContain('Empty Presentation');\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSlidesAPI.presentations.get.mockRejectedValue(new Error('API Error'));\n\n      const result = await slidesService.getText({\n        presentationId: 'error-presentation-id',\n      });\n\n      expect(result.content[0].type).toBe('text');\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('API Error');\n    });\n  });\n\n  describe('getMetadata', () => {\n    it('should retrieve presentation metadata', async () => {\n      const mockPresentation = {\n        data: {\n          presentationId: 'test-id',\n          title: 'Test Presentation',\n          slides: [{ objectId: 'slide1' }, { objectId: 'slide2' }],\n          pageSize: { width: { magnitude: 10 }, height: { magnitude: 7.5 } },\n          masters: [{ objectId: 'master1' }],\n          layouts: [{ objectId: 'layout1' }],\n          notesMaster: { objectId: 'notesMaster1' },\n        },\n      };\n\n      mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);\n\n      const result = await slidesService.getMetadata({\n        presentationId: 'test-id',\n      });\n      const metadata = JSON.parse(result.content[0].text);\n\n      expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({\n        presentationId: 'test-id',\n        fields:\n          'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts',\n      });\n\n      expect(metadata.presentationId).toBe('test-id');\n      expect(metadata.title).toBe('Test Presentation');\n      expect(metadata.slideCount).toBe(2);\n      expect(metadata.slides).toEqual([\n        { objectId: 'slide1' },\n        { objectId: 'slide2' },\n      ]);\n      expect(metadata.hasMasters).toBe(true);\n      expect(metadata.hasLayouts).toBe(true);\n      expect(metadata.hasNotesMaster).toBe(true);\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSlidesAPI.presentations.get.mockRejectedValue(\n        new Error('Metadata Error'),\n      );\n\n      const result = await slidesService.getMetadata({\n        presentationId: 'error-id',\n      });\n      const response = JSON.parse(result.content[0].text);\n\n      expect(response.error).toBe('Metadata Error');\n    });\n  });\n\n  describe('getImages', () => {\n    it('should extract images from a presentation', async () => {\n      const mockPresentation = {\n        data: {\n          slides: [\n            {\n              objectId: 'slide1',\n              pageElements: [\n                {\n                  objectId: 'image_element_1',\n                  title: 'Test Image',\n                  description: 'A description of the test image',\n                  image: {\n                    contentUrl: 'http://example.com/image1.png',\n                    sourceUrl: 'http://example.com/original1.png',\n                  },\n                },\n              ],\n            },\n            {\n              objectId: 'slide2',\n              pageElements: [\n                {\n                  objectId: 'image_element_2',\n                  image: {\n                    contentUrl: 'http://example.com/image2.png',\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      };\n\n      mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);\n\n      const result = await slidesService.getImages({\n        presentationId: 'test-presentation-id',\n        localPath: '/tmp/test-images',\n      });\n\n      expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({\n        presentationId: 'test-presentation-id',\n        fields:\n          'slides(objectId,pageElements(objectId,title,description,image(contentUrl,sourceUrl)))',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.images).toHaveLength(2);\n      expect(response.images[0].slideIndex).toBe(1);\n      expect(response.images[0].slideObjectId).toBe('slide1');\n      expect(response.images[0].elementObjectId).toBe('image_element_1');\n      expect(response.images[1].slideIndex).toBe(2);\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSlidesAPI.presentations.get.mockRejectedValue(new Error('API Error'));\n\n      const result = await slidesService.getImages({\n        presentationId: 'error-id',\n        localPath: '/tmp/test-images',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('API Error');\n    });\n\n    it('should download images when localPath is provided', async () => {\n      const mockPresentation = {\n        data: {\n          slides: [\n            {\n              objectId: 'slide1',\n              pageElements: [\n                {\n                  objectId: 'image1',\n                  image: { contentUrl: 'http://example.com/image1.png' },\n                },\n              ],\n            },\n          ],\n        },\n      };\n\n      mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);\n\n      const result = await slidesService.getImages({\n        presentationId: 'test-id',\n        localPath: '/absolute/path/to/dir',\n      });\n\n      expect(fs.mkdir).toHaveBeenCalledWith('/absolute/path/to/dir', {\n        recursive: true,\n      });\n      expect(fs.writeFile).toHaveBeenCalled();\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.images[0].localPath).toBe(\n        '/absolute/path/to/dir/slide_1_image1.png',\n      );\n    });\n  });\n\n  describe('getSlideThumbnail', () => {\n    beforeEach(() => {\n      mockSlidesAPI.presentations.pages = {\n        getThumbnail: jest.fn(),\n      };\n    });\n\n    it('should download thumbnail when localPath is provided', async () => {\n      const mockThumbnail = {\n        data: {\n          width: 800,\n          height: 600,\n          contentUrl: 'http://example.com/thumbnail.png',\n        },\n      };\n\n      mockSlidesAPI.presentations.pages.getThumbnail.mockResolvedValue(\n        mockThumbnail,\n      );\n\n      const result = await slidesService.getSlideThumbnail({\n        presentationId: 'test-presentation-id',\n        slideObjectId: 'slide1',\n        localPath: '/absolute/path/to/thumb.png',\n      });\n\n      expect(\n        mockSlidesAPI.presentations.pages.getThumbnail,\n      ).toHaveBeenCalledWith({\n        presentationId: 'test-presentation-id',\n        pageObjectId: 'slide1',\n      });\n\n      expect(fs.writeFile).toHaveBeenCalledWith(\n        '/absolute/path/to/thumb.png',\n        expect.any(Buffer),\n      );\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.contentUrl).toBe('http://example.com/thumbnail.png');\n      expect(response.localPath).toBe('/absolute/path/to/thumb.png');\n    });\n\n    it('should handle errors gracefully', async () => {\n      mockSlidesAPI.presentations.pages.getThumbnail.mockRejectedValue(\n        new Error('API Error'),\n      );\n\n      const result = await slidesService.getSlideThumbnail({\n        presentationId: 'error-id',\n        slideObjectId: 'slide1',\n        localPath: '/tmp/thumb.png',\n      });\n\n      const response = JSON.parse(result.content[0].text);\n      expect(response.error).toBe('API Error');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/services/TimeService.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { TimeService } from '../../services/TimeService';\n\ndescribe('TimeService', () => {\n  let timeService: TimeService;\n  const mockDate = new Date('2025-08-19T12:34:56Z');\n\n  beforeEach(() => {\n    timeService = new TimeService();\n    jest.useFakeTimers();\n    jest.setSystemTime(mockDate);\n  });\n\n  afterEach(() => {\n    jest.useRealTimers();\n  });\n\n  describe('getCurrentDate', () => {\n    it('should return the current date with utc, local, and timeZone fields', async () => {\n      const result = await timeService.getCurrentDate();\n      const parsed = JSON.parse(result.content[0].text);\n      const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n      expect(parsed.utc).toEqual('2025-08-19');\n      expect(parsed.local).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      expect(parsed.timeZone).toEqual(expectedTimeZone);\n    });\n  });\n\n  describe('getCurrentTime', () => {\n    it('should return the current time with utc, local, and timeZone fields', async () => {\n      const result = await timeService.getCurrentTime();\n      const parsed = JSON.parse(result.content[0].text);\n      const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n      expect(parsed.utc).toEqual('12:34:56');\n      expect(parsed.local).toMatch(/^\\d{2}:\\d{2}:\\d{2}$/);\n      expect(parsed.timeZone).toEqual(expectedTimeZone);\n    });\n  });\n\n  describe('getTimeZone', () => {\n    it('should return the local timezone', async () => {\n      const result = await timeService.getTimeZone();\n      const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n      expect(result.content[0].text).toEqual(\n        JSON.stringify({ timeZone: expectedTimeZone }),\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/setup.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Test setup file for Jest\n// This file runs before all tests\nimport { afterAll, jest } from '@jest/globals';\n\n// Mock console methods to reduce noise in test output\nglobal.console = {\n  ...console,\n  // Keep errors and warnings\n  error: jest.fn(console.error),\n  warn: jest.fn(console.warn),\n  // Silence other logs during tests unless explicitly needed\n  log: jest.fn(),\n  info: jest.fn(),\n  debug: jest.fn(),\n};\n\n// Set test environment variables\nprocess.env.NODE_ENV = 'test';\n\n// Increase timeout for integration tests if needed\njest.setTimeout(10000);\n\n// Clean up after all tests\nafterAll(() => {\n  jest.clearAllMocks();\n  jest.restoreAllMocks();\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/tool-normalization.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { jest } from '@jest/globals';\nimport { applyToolNameNormalization } from '../utils/tool-normalization';\n\ndescribe('Tool Name Normalization', () => {\n  it('should normalize dots to underscores when useDotNames is false', () => {\n    const mockRegisterTool = jest.fn();\n    const server = { registerTool: mockRegisterTool } as unknown as McpServer;\n\n    applyToolNameNormalization(server, false);\n    server.registerTool(\n      'test.tool',\n      { inputSchema: z.object({}) },\n      async () => ({ content: [] }),\n    );\n\n    expect(mockRegisterTool).toHaveBeenCalledWith(\n      'test_tool',\n      expect.any(Object),\n      expect.any(Function),\n    );\n  });\n\n  it('should preserve dots when useDotNames is true', () => {\n    const mockRegisterTool = jest.fn();\n    const server = { registerTool: mockRegisterTool } as unknown as McpServer;\n\n    applyToolNameNormalization(server, true);\n    server.registerTool(\n      'test.tool',\n      { inputSchema: z.object({}) },\n      async () => ({ content: [] }),\n    );\n\n    expect(mockRegisterTool).toHaveBeenCalledWith(\n      'test.tool',\n      expect.any(Object),\n      expect.any(Function),\n    );\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect } from '@jest/globals';\nimport { escapeQueryString } from '../../utils/DriveQueryBuilder';\n\ndescribe('DriveQueryBuilder', () => {\n  describe('escapeQueryString', () => {\n    it('should escape backslashes', () => {\n      expect(escapeQueryString('path\\\\to\\\\file')).toBe('path\\\\\\\\to\\\\\\\\file');\n    });\n\n    it('should escape single quotes', () => {\n      expect(escapeQueryString(\"it's a test\")).toBe(\"it\\\\'s a test\");\n    });\n\n    it('should escape both backslashes and single quotes', () => {\n      expect(escapeQueryString(\"John's Presentation\\\\2024\")).toBe(\n        \"John\\\\'s Presentation\\\\\\\\2024\",\n      );\n    });\n\n    it('should handle strings without special characters', () => {\n      expect(escapeQueryString('hello world')).toBe('hello world');\n    });\n\n    it('should handle empty strings', () => {\n      expect(escapeQueryString('')).toBe('');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/IdUtils.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect } from '@jest/globals';\nimport { extractDocId } from '../../utils/IdUtils';\n\ndescribe('IdUtils', () => {\n  describe('extractDocId', () => {\n    it('should extract document ID from a full Google Docs URL', () => {\n      const url =\n        'https://docs.google.com/document/d/1a2b3c4d5e6f7g8h9i0j/edit';\n      const result = extractDocId(url);\n      expect(result).toBe('1a2b3c4d5e6f7g8h9i0j');\n    });\n\n    it('should extract document ID from URL with additional parameters', () => {\n      const url =\n        'https://docs.google.com/document/d/abc123-XYZ_789/edit?usp=sharing';\n      const result = extractDocId(url);\n      expect(result).toBe('abc123-XYZ_789');\n    });\n\n    it('should extract document ID from URL with preview path', () => {\n      const url = 'https://docs.google.com/document/d/test-doc-id-123/preview';\n      const result = extractDocId(url);\n      expect(result).toBe('test-doc-id-123');\n    });\n\n    it('should extract document ID from URL without protocol', () => {\n      const url = 'docs.google.com/document/d/my_document_id/view';\n      const result = extractDocId(url);\n      expect(result).toBe('my_document_id');\n    });\n\n    it('should return undefined when raw document ID is passed directly', () => {\n      const docId = '1a2b3c4d5e6f7g8h9i0j';\n      const result = extractDocId(docId);\n      expect(result).toBeUndefined();\n    });\n\n    it('should return undefined for document ID with underscores and hyphens', () => {\n      const docId = 'doc_id-with-special_chars_123';\n      const result = extractDocId(docId);\n      expect(result).toBeUndefined();\n    });\n\n    it('should return undefined if no pattern matches', () => {\n      const randomString = 'not a doc id or url';\n      const result = extractDocId(randomString);\n      expect(result).toBeUndefined();\n    });\n\n    it('should return undefined for empty string', () => {\n      const result = extractDocId('');\n      expect(result).toBeUndefined();\n    });\n\n    it('should extract from partial URL path', () => {\n      const partialPath = '/document/d/abc123xyz/';\n      const result = extractDocId(partialPath);\n      expect(result).toBe('abc123xyz');\n    });\n\n    it('should handle URL with multiple document paths (edge case)', () => {\n      // Should extract the first match\n      const url = '/document/d/first123/document/d/second456/';\n      const result = extractDocId(url);\n      expect(result).toBe('first123');\n    });\n\n    it('should handle very long document IDs', () => {\n      const longId = 'a'.repeat(100) + '_' + 'b'.repeat(50);\n      const url = `https://docs.google.com/document/d/${longId}/edit`;\n      const result = extractDocId(url);\n      expect(result).toBe(longId);\n    });\n\n    it('should handle document ID with only numbers', () => {\n      const url = 'https://docs.google.com/document/d/1234567890/edit';\n      const result = extractDocId(url);\n      expect(result).toBe('1234567890');\n    });\n\n    it('should handle document ID with only letters', () => {\n      const url = 'https://docs.google.com/document/d/abcdefghij/edit';\n      const result = extractDocId(url);\n      expect(result).toBe('abcdefghij');\n    });\n\n    it('should handle malformed URLs gracefully', () => {\n      const malformedUrl = 'https://docs.google.com/document/edit';\n      const result = extractDocId(malformedUrl);\n      // Should return the input as-is when pattern doesn't match\n      expect(result).toBeUndefined();\n    });\n\n    it('should be case sensitive for document IDs', () => {\n      const url = 'https://docs.google.com/document/d/AbCdEfGhIj/edit';\n      const result = extractDocId(url);\n      expect(result).toBe('AbCdEfGhIj');\n    });\n\n    it('should extract document ID from a complex URL with resourcekey', () => {\n      const url =\n        'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI/edit?resourcekey=0-X_p2TPxpk0visLTHHMF7Yg&tab=t.0';\n      const result = extractDocId(url);\n      expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI');\n    });\n\n    it('should extract document ID from a URL without a trailing slash', () => {\n      const url =\n        'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI';\n      const result = extractDocId(url);\n      expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/MimeHelper.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect } from '@jest/globals';\nimport { MimeHelper } from '../../utils/MimeHelper';\n\ndescribe('MimeHelper', () => {\n  describe('createMimeMessage', () => {\n    it('should create a basic plain text email', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'Test Subject',\n        body: 'This is a test email body.',\n      });\n\n      // Decode the message to verify its structure\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('To: recipient@example.com');\n      expect(decoded).toContain('Subject: =?utf-8?B?VGVzdCBTdWJqZWN0?=');\n      expect(decoded).toContain('Content-Type: text/plain; charset=utf-8');\n      expect(decoded).toContain('This is a test email body.');\n    });\n\n    it('should create an HTML email', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'HTML Email',\n        body: '<h1>Hello World</h1><p>This is HTML content.</p>',\n        isHtml: true,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('Content-Type: text/html; charset=utf-8');\n      expect(decoded).toContain('<h1>Hello World</h1>');\n    });\n\n    it('should include optional headers when provided', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'Full Headers Test',\n        body: 'Test body',\n        from: 'sender@example.com',\n        cc: 'cc@example.com',\n        bcc: 'bcc@example.com',\n        replyTo: 'reply@example.com',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('From: sender@example.com');\n      expect(decoded).toContain('To: recipient@example.com');\n      expect(decoded).toContain('Cc: cc@example.com');\n      expect(decoded).toContain('Bcc: bcc@example.com');\n      expect(decoded).toContain('Reply-To: reply@example.com');\n    });\n\n    it('should handle UTF-8 subjects correctly', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'Test with emoji 🎉 and special chars é ñ',\n        body: 'Test body',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      // The subject should be base64 encoded\n      expect(decoded).toContain('Subject: =?utf-8?B?');\n\n      // Decode the subject to verify it's correct\n      const subjectMatch = decoded.match(/Subject: =\\?utf-8\\?B\\?([^?]+)\\?=/);\n      if (subjectMatch) {\n        const decodedSubject = Buffer.from(subjectMatch[1], 'base64').toString(\n          'utf-8',\n        );\n        expect(decodedSubject).toBe('Test with emoji 🎉 and special chars é ñ');\n      }\n    });\n\n    it('should properly format the MIME message with CRLF line endings', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'CRLF Test',\n        body: 'Line 1\\nLine 2',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      // Should use CRLF (\\r\\n) as line separators\n      expect(decoded).toContain('\\r\\n');\n      expect(decoded.split('\\r\\n').length).toBeGreaterThan(3);\n    });\n\n    it('should handle multiple recipients in to field', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient1@example.com, recipient2@example.com',\n        subject: 'Multiple Recipients',\n        body: 'Test body',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain(\n        'To: recipient1@example.com, recipient2@example.com',\n      );\n    });\n\n    it('should encode to base64url format (no padding, URL-safe characters)', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'Base64URL Test',\n        body: 'Test content that should be encoded',\n      });\n\n      // Check that it doesn't contain standard base64 characters\n      expect(encoded).not.toContain('+');\n      expect(encoded).not.toContain('/');\n      expect(encoded).not.toContain('=');\n\n      // Should only contain base64url characters\n      expect(encoded).toMatch(/^[A-Za-z0-9\\-_]+$/);\n    });\n    it('should include In-Reply-To and References headers when provided', () => {\n      const messageId = '<original-message-id@example.com>';\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'Re: Original Subject',\n        body: 'Reply body',\n        inReplyTo: messageId,\n        references: messageId,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain(`In-Reply-To: ${messageId}`);\n      expect(decoded).toContain(`References: ${messageId}`);\n    });\n\n    it('should not include In-Reply-To or References headers when not provided', () => {\n      const encoded = MimeHelper.createMimeMessage({\n        to: 'recipient@example.com',\n        subject: 'New Message',\n        body: 'Body',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).not.toContain('In-Reply-To:');\n      expect(decoded).not.toContain('References:');\n    });\n  });\n\n  describe('createMimeMessageWithAttachments', () => {\n    it('should create a message without attachments when none provided', () => {\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'No Attachments',\n        body: 'Simple message',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      // Should not contain multipart boundary\n      expect(decoded).not.toContain('Content-Type: multipart/mixed');\n      expect(decoded).toContain('Content-Type: text/plain; charset=utf-8');\n    });\n\n    it('should create a multipart message with attachments', () => {\n      const attachments = [\n        {\n          filename: 'test.txt',\n          content: Buffer.from('Hello, World!'),\n          contentType: 'text/plain',\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'With Attachment',\n        body: 'Message with attachment',\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('Content-Type: multipart/mixed; boundary=');\n      expect(decoded).toContain(\n        'Content-Disposition: attachment; filename=\"test.txt\"',\n      );\n      expect(decoded).toContain('Content-Type: text/plain');\n      expect(decoded).toContain('Content-Transfer-Encoding: base64');\n    });\n\n    it('should handle multiple attachments', () => {\n      const attachments = [\n        {\n          filename: 'file1.txt',\n          content: 'First file content',\n          contentType: 'text/plain',\n        },\n        {\n          filename: 'file2.pdf',\n          content: Buffer.from('PDF content'),\n          contentType: 'application/pdf',\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Multiple Attachments',\n        body: 'Message with multiple attachments',\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('filename=\"file1.txt\"');\n      expect(decoded).toContain('filename=\"file2.pdf\"');\n      expect(decoded).toContain('Content-Type: text/plain');\n      expect(decoded).toContain('Content-Type: application/pdf');\n    });\n\n    it('should use default content type for attachments without specified type', () => {\n      const attachments = [\n        {\n          filename: 'unknown.bin',\n          content: Buffer.from('Binary content'),\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Default Content Type',\n        body: 'Message',\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('Content-Type: application/octet-stream');\n    });\n\n    it('should properly format attachment content in 76-character lines', () => {\n      const longContent = 'a'.repeat(200); // Long content that needs to be wrapped\n      const attachments = [\n        {\n          filename: 'long.txt',\n          content: Buffer.from(longContent),\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Long Attachment',\n        body: 'Message',\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      // Find the base64 encoded attachment content\n      const lines = decoded.split('\\r\\n');\n      const attachmentStart = lines.findIndex((line) =>\n        line.includes('Content-Transfer-Encoding: base64'),\n      );\n\n      if (attachmentStart !== -1) {\n        // Check lines after the attachment header\n        for (let i = attachmentStart + 2; i < lines.length; i++) {\n          const line = lines[i];\n          if (line.startsWith('--')) break; // Reached boundary\n          if (line.length > 0) {\n            expect(line.length).toBeLessThanOrEqual(76);\n          }\n        }\n      }\n    });\n\n    it('should handle HTML body with attachments', () => {\n      const attachments = [\n        {\n          filename: 'doc.html',\n          content: '<html><body>HTML Doc</body></html>',\n          contentType: 'text/html',\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'HTML with Attachment',\n        body: '<p>HTML Message Body</p>',\n        isHtml: true,\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      // Body should be HTML\n      expect(decoded).toMatch(\n        /Content-Type: text\\/html; charset=utf-8\\r\\n\\r\\n<p>HTML Message Body<\\/p>/,\n      );\n      // Attachment should also be present\n      expect(decoded).toContain('filename=\"doc.html\"');\n    });\n\n    it('should include all optional headers with attachments', () => {\n      const attachments = [\n        {\n          filename: 'test.txt',\n          content: 'Test',\n        },\n      ];\n\n      const encoded = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Full Headers with Attachments',\n        body: 'Test body',\n        from: 'sender@example.com',\n        cc: 'cc@example.com',\n        bcc: 'bcc@example.com',\n        attachments,\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(encoded);\n\n      expect(decoded).toContain('From: sender@example.com');\n      expect(decoded).toContain('Cc: cc@example.com');\n      expect(decoded).toContain('Bcc: bcc@example.com');\n      expect(decoded).toContain('MIME-Version: 1.0');\n    });\n\n    it('should create unique boundary for each message', () => {\n      const attachments = [\n        {\n          filename: 'test.txt',\n          content: 'Test',\n        },\n      ];\n\n      const encoded1 = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Message 1',\n        body: 'Body 1',\n        attachments,\n      });\n\n      const encoded2 = MimeHelper.createMimeMessageWithAttachments({\n        to: 'recipient@example.com',\n        subject: 'Message 2',\n        body: 'Body 2',\n        attachments,\n      });\n\n      const decoded1 = MimeHelper.decodeBase64Url(encoded1);\n      const decoded2 = MimeHelper.decodeBase64Url(encoded2);\n\n      const boundary1Match = decoded1.match(/boundary=\"([^\"]+)\"/);\n      const boundary2Match = decoded2.match(/boundary=\"([^\"]+)\"/);\n\n      expect(boundary1Match).toBeTruthy();\n      expect(boundary2Match).toBeTruthy();\n      expect(boundary1Match![1]).not.toBe(boundary2Match![1]);\n    });\n  });\n\n  describe('decodeBase64Url', () => {\n    it('should decode base64url encoded strings', () => {\n      const original = 'Hello, World! This is a test.';\n      const base64url = Buffer.from(original)\n        .toString('base64')\n        .replace(/\\+/g, '-')\n        .replace(/\\//g, '_')\n        .replace(/=+$/, '');\n\n      const decoded = MimeHelper.decodeBase64Url(base64url);\n\n      expect(decoded).toBe(original);\n    });\n\n    it('should handle strings without padding', () => {\n      const base64url = 'SGVsbG8'; // \"Hello\" without padding\n      const decoded = MimeHelper.decodeBase64Url(base64url);\n\n      expect(decoded).toBe('Hello');\n    });\n\n    it('should convert URL-safe characters back to standard base64', () => {\n      const base64url = 'SGVsbG8-V29ybGRfIQ'; // Contains - and _\n      const decoded = MimeHelper.decodeBase64Url(base64url);\n\n      expect(decoded).toBeTruthy();\n      expect(typeof decoded).toBe('string');\n    });\n\n    it('should handle empty strings', () => {\n      const decoded = MimeHelper.decodeBase64Url('');\n\n      expect(decoded).toBe('');\n    });\n\n    it('should properly decode a complete MIME message', () => {\n      const mimeMessage = MimeHelper.createMimeMessage({\n        to: 'test@example.com',\n        subject: 'Test',\n        body: 'Test body',\n      });\n\n      const decoded = MimeHelper.decodeBase64Url(mimeMessage);\n\n      expect(decoded).toContain('To: test@example.com');\n      expect(decoded).toContain('Test body');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/config.test.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\n\njest.mock('../../utils/logger', () => ({\n  logToFile: jest.fn(),\n}));\n\ndescribe('config', () => {\n  const originalEnv = process.env;\n\n  beforeEach(() => {\n    jest.resetModules();\n    process.env = { ...originalEnv };\n    delete process.env['WORKSPACE_CLIENT_ID'];\n    delete process.env['WORKSPACE_CLOUD_FUNCTION_URL'];\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n  });\n\n  it('should return default config when no env vars are set', async () => {\n    const { loadConfig } = await import('../../utils/config');\n    const config = loadConfig();\n\n    expect(config.clientId).toBe(\n      '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com',\n    );\n    expect(config.cloudFunctionUrl).toBe(\n      'https://google-workspace-extension.geminicli.com',\n    );\n  });\n\n  it('should use env vars when set', async () => {\n    process.env['WORKSPACE_CLIENT_ID'] = 'custom-client-id';\n    process.env['WORKSPACE_CLOUD_FUNCTION_URL'] = 'https://custom.example.com';\n\n    const { loadConfig } = await import('../../utils/config');\n    const config = loadConfig();\n\n    expect(config.clientId).toBe('custom-client-id');\n    expect(config.cloudFunctionUrl).toBe('https://custom.example.com');\n  });\n\n  it('should fall back to defaults for partial env var configuration', async () => {\n    process.env['WORKSPACE_CLIENT_ID'] = 'custom-client-id';\n\n    const { loadConfig } = await import('../../utils/config');\n    const config = loadConfig();\n\n    expect(config.clientId).toBe('custom-client-id');\n    expect(config.cloudFunctionUrl).toBe(\n      'https://google-workspace-extension.geminicli.com',\n    );\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/logger.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  jest,\n  beforeEach,\n  afterEach,\n} from '@jest/globals';\nimport * as path from 'node:path';\n\n// Mock fs/promises module BEFORE any imports that use it\njest.mock('fs/promises');\n\ndescribe('logger', () => {\n  let consoleErrorSpy: any;\n  let logToFile: (message: string) => void;\n  let setLoggingEnabled: (enabled: boolean) => void;\n  let fs: any;\n\n  async function setupLogger(appendFileMock?: any) {\n    jest.resetModules();\n    jest.doMock('fs/promises', () => ({\n      mkdir: jest.fn(() => Promise.resolve()),\n      appendFile: appendFileMock || jest.fn(() => Promise.resolve()),\n    }));\n\n    fs = await import('node:fs/promises');\n    const loggerModule = await import('../../utils/logger');\n    logToFile = loggerModule.logToFile;\n    setLoggingEnabled = loggerModule.setLoggingEnabled;\n    setLoggingEnabled(true);\n    jest.clearAllMocks();\n  }\n\n  beforeEach(() => {\n    // Clear all mocks\n    jest.clearAllMocks();\n\n    // Clear module cache to ensure fresh imports\n    jest.resetModules();\n\n    // Spy on console.error\n    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    jest.restoreAllMocks();\n  });\n\n  describe('module initialization', () => {\n    it('should create log directory on module load', async () => {\n      // Set up mocks\n      jest.doMock('fs/promises', () => ({\n        mkdir: jest.fn(() => Promise.resolve()),\n        appendFile: jest.fn(() => Promise.resolve()),\n      }));\n\n      // Import the module (this triggers initialization)\n      await import('../../utils/logger');\n\n      // Get the mocked fs module\n      fs = await import('node:fs/promises');\n\n      // Wait for async initialization\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('logs'), {\n        recursive: true,\n      });\n    });\n\n    it('should handle directory creation errors gracefully', async () => {\n      const mkdirError = new Error('Permission denied');\n\n      // Set up mocks\n      jest.doMock('fs/promises', () => ({\n        mkdir: jest.fn(() => Promise.reject(mkdirError)),\n        appendFile: jest.fn(() => Promise.resolve()),\n      }));\n\n      // Import the module\n      await import('../../utils/logger');\n\n      // Wait for async initialization\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        'Could not create log directory:',\n        mkdirError,\n      );\n    });\n  });\n\n  describe('logToFile', () => {\n    beforeEach(async () => {\n      await setupLogger();\n    });\n\n    it('should append message with timestamp to log file', async () => {\n      const testMessage = 'Test log message';\n      const mockDate = new Date('2024-01-01T12:00:00.000Z');\n      jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);\n\n      logToFile(testMessage);\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fs.appendFile).toHaveBeenCalledWith(\n        expect.stringContaining('server.log'),\n        '2024-01-01T12:00:00.000Z - Test log message\\n',\n      );\n    });\n\n    it('should handle multiple log messages', async () => {\n      logToFile('First message');\n      logToFile('Second message');\n      logToFile('Third message');\n\n      // Wait for async operations\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fs.appendFile).toHaveBeenCalledTimes(3);\n      expect(fs.appendFile).toHaveBeenNthCalledWith(\n        1,\n        expect.stringContaining('server.log'),\n        expect.stringContaining('First message'),\n      );\n      expect(fs.appendFile).toHaveBeenNthCalledWith(\n        2,\n        expect.stringContaining('server.log'),\n        expect.stringContaining('Second message'),\n      );\n      expect(fs.appendFile).toHaveBeenNthCalledWith(\n        3,\n        expect.stringContaining('server.log'),\n        expect.stringContaining('Third message'),\n      );\n    });\n\n    it('should log to console.error when file write fails', async () => {\n      const writeError = new Error('Disk full');\n      await setupLogger(jest.fn(() => Promise.reject(writeError)));\n\n      logToFile('Failed write test');\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(consoleErrorSpy).toHaveBeenCalledWith(\n        'Failed to write to log file:',\n        writeError,\n      );\n    });\n\n    it('should format log message correctly', async () => {\n      const mockDate = new Date('2024-12-25T18:30:45.123Z');\n      jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);\n\n      logToFile('Holiday log entry');\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      const expectedMessage = '2024-12-25T18:30:45.123Z - Holiday log entry\\n';\n      expect(fs.appendFile).toHaveBeenCalledWith(\n        expect.any(String),\n        expectedMessage,\n      );\n    });\n\n    it('should handle empty messages', async () => {\n      const mockDate = new Date('2024-01-01T12:00:00.000Z');\n      jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);\n      logToFile('');\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fs.appendFile).toHaveBeenCalledWith(\n        expect.stringContaining('server.log'),\n        '2024-01-01T12:00:00.000Z - \\n',\n      );\n    });\n\n    it('should handle special characters in messages', async () => {\n      const specialMessage = 'Message with \\n newline, \\t tab, and \"quotes\"';\n\n      logToFile(specialMessage);\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fs.appendFile).toHaveBeenCalledWith(\n        expect.stringContaining('server.log'),\n        expect.stringContaining(specialMessage),\n      );\n    });\n\n    it('should use correct log file path', async () => {\n      logToFile('Path test');\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      const callArgs = (fs.appendFile as jest.Mock).mock.calls[0];\n      const logPath = callArgs[0] as string;\n\n      expect(logPath).toContain('logs');\n      expect(logPath).toContain('server.log');\n      expect(path.isAbsolute(logPath)).toBe(true);\n    });\n\n    it('should not throw when appendFile fails', async () => {\n      await setupLogger(\n        jest.fn(() => Promise.reject(new Error('Write failed'))),\n      );\n\n      // Should not throw\n      expect(() => logToFile('Test message')).not.toThrow();\n\n      // Wait for async operation\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(consoleErrorSpy).toHaveBeenCalled();\n    });\n\n    it('should not log when logging is disabled', () => {\n      setLoggingEnabled(false);\n      const testMessage = 'Test log message';\n\n      logToFile(testMessage);\n\n      expect(fs.appendFile).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/paths.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport path from 'node:path';\nimport * as fs from 'node:fs';\nimport { PROJECT_ROOT } from '../../utils/paths';\n\ndescribe('paths utils', () => {\n  describe('PROJECT_ROOT', () => {\n    it('should resolve to the workspace root directory', () => {\n      // The project root should contain gemini-extension.json\n      // Since we are searching for gemini-extension.json which is in the root 'workspace',\n      // not 'workspace-server', the path should NOT end with 'workspace-server'.\n      const extensionConfigPath = path.join(\n        PROJECT_ROOT,\n        'gemini-extension.json',\n      );\n      expect(fs.existsSync(extensionConfigPath)).toBe(true);\n\n      // The root should be the parent of workspace-server in this monorepo setup\n      // PROJECT_ROOT = .../workspace\n      // __dirname = .../workspace/workspace-server/src/__tests__/utils\n      expect(PROJECT_ROOT.endsWith('workspace-server')).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {\n  describe,\n  it,\n  expect,\n  beforeEach,\n  jest,\n  afterEach,\n} from '@jest/globals';\nimport { openBrowserSecurely } from '../../utils/secure-browser-launcher';\nimport { platform } from 'node:os';\nimport { EventEmitter } from 'node:events';\nimport { ChildProcess } from 'node:child_process';\n\njest.mock('node:os');\n\nconst mockPlatform = platform as jest.Mock;\n\ndescribe('secure-browser-launcher', () => {\n  let mockChild: EventEmitter;\n  let mockExecFile: jest.Mock;\n\n  beforeEach(() => {\n    mockChild = new EventEmitter();\n    mockExecFile = jest.fn().mockReturnValue(mockChild as ChildProcess);\n    mockPlatform.mockReturnValue('darwin'); // Default to macOS\n  });\n\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  function simulateSuccess() {\n    process.nextTick(() => {\n      mockChild.emit('exit', 0);\n    });\n  }\n\n  function simulateFailure(error = new Error('Command failed')) {\n    process.nextTick(() => {\n      mockChild.emit('error', error);\n    });\n  }\n\n  describe('URL validation', () => {\n    it('should allow valid HTTP URLs', async () => {\n      const openPromise = openBrowserSecurely(\n        'http://example.com',\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'open',\n        ['http://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should allow valid HTTPS URLs', async () => {\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'open',\n        ['https://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should reject non-HTTP(S) protocols', async () => {\n      await expect(\n        openBrowserSecurely('file:///etc/passwd', mockExecFile as any),\n      ).rejects.toThrow('Unsafe protocol');\n      await expect(\n        openBrowserSecurely('javascript:alert(1)', mockExecFile as any),\n      ).rejects.toThrow('Unsafe protocol');\n      await expect(\n        openBrowserSecurely('ftp://example.com', mockExecFile as any),\n      ).rejects.toThrow('Unsafe protocol');\n    });\n\n    it('should reject invalid URLs', async () => {\n      await expect(\n        openBrowserSecurely('not-a-url', mockExecFile as any),\n      ).rejects.toThrow('Invalid URL');\n      await expect(\n        openBrowserSecurely('', mockExecFile as any),\n      ).rejects.toThrow('Invalid URL');\n    });\n\n    it('should reject URLs with control characters', async () => {\n      await expect(\n        openBrowserSecurely(\n          'http://example.com\\nmalicious-command',\n          mockExecFile as any,\n        ),\n      ).rejects.toThrow('invalid characters');\n      await expect(\n        openBrowserSecurely(\n          'http://example.com\\rmalicious-command',\n          mockExecFile as any,\n        ),\n      ).rejects.toThrow('invalid characters');\n      await expect(\n        openBrowserSecurely('http://example.com\\x00', mockExecFile as any),\n      ).rejects.toThrow('invalid characters');\n    });\n  });\n\n  describe('Command injection prevention', () => {\n    it('should prevent PowerShell command injection on Windows', async () => {\n      mockPlatform.mockReturnValue('win32');\n      const maliciousUrl =\n        \"http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))\";\n\n      const openPromise = openBrowserSecurely(\n        maliciousUrl,\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'powershell.exe',\n        [\n          '-NoProfile',\n          '-NonInteractive',\n          '-WindowStyle',\n          'Hidden',\n          '-Command',\n          `Start-Process '${maliciousUrl.replace(/'/g, \"''\")}'`,\n        ],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should handle URLs with special shell characters safely', async () => {\n      const urlsWithSpecialChars = [\n        'http://example.com/path?param=value&other=$value',\n        'http://example.com/path#fragment;command',\n        'http://example.com/$(whoami)',\n        'http://example.com/`command`',\n        'http://example.com/|pipe',\n        'http://example.com/>redirect',\n      ];\n\n      for (const url of urlsWithSpecialChars) {\n        const openPromise = openBrowserSecurely(url, mockExecFile as any);\n        simulateSuccess();\n        await expect(openPromise).resolves.toBeUndefined();\n        expect(mockExecFile).toHaveBeenCalledWith(\n          'open',\n          [url],\n          expect.any(Object),\n          expect.any(Function),\n        );\n      }\n    });\n\n    it('should properly escape single quotes in URLs on Windows', async () => {\n      mockPlatform.mockReturnValue('win32');\n      const urlWithSingleQuotes =\n        \"http://example.com/path?name=O'Brien&test='value'\";\n\n      const openPromise = openBrowserSecurely(\n        urlWithSingleQuotes,\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'powershell.exe',\n        [\n          '-NoProfile',\n          '-NonInteractive',\n          '-WindowStyle',\n          'Hidden',\n          '-Command',\n          `Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`,\n        ],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n  });\n\n  describe('Platform-specific behavior', () => {\n    it('should use correct command on macOS', async () => {\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'open',\n        ['https://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should use PowerShell on Windows', async () => {\n      mockPlatform.mockReturnValue('win32');\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'powershell.exe',\n        [\n          '-NoProfile',\n          '-NonInteractive',\n          '-WindowStyle',\n          'Hidden',\n          '-Command',\n          `Start-Process 'https://example.com'`,\n        ],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should use xdg-open on Linux', async () => {\n      mockPlatform.mockReturnValue('linux');\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n      simulateSuccess();\n      await expect(openPromise).resolves.toBeUndefined();\n      expect(mockExecFile).toHaveBeenCalledWith(\n        'xdg-open',\n        ['https://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n\n    it('should throw on unsupported platforms', async () => {\n      mockPlatform.mockReturnValue('aix');\n      await expect(\n        openBrowserSecurely('https://example.com', mockExecFile as any),\n      ).rejects.toThrow('Unsupported platform');\n    });\n  });\n\n  describe('Error handling', () => {\n    it('should handle browser launch failures gracefully', async () => {\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n      simulateFailure();\n      await expect(openPromise).rejects.toThrow('Failed to open browser');\n    });\n\n    it('should try fallback browsers on Linux', async () => {\n      mockPlatform.mockReturnValue('linux');\n\n      const mockChild2 = new EventEmitter();\n      mockExecFile.mockImplementationOnce(() => {\n        // Defer the emit call to allow the 'on' handlers to be set up.\n        process.nextTick(() => {\n          mockChild.emit('error', new Error('xdg-open not found'));\n        });\n        return mockChild as ChildProcess;\n      });\n      mockExecFile.mockImplementationOnce(() => {\n        process.nextTick(() => {\n          mockChild2.emit('exit', 0);\n        });\n        return mockChild2 as ChildProcess;\n      });\n\n      const openPromise = openBrowserSecurely(\n        'https://example.com',\n        mockExecFile as any,\n      );\n\n      await expect(openPromise).resolves.toBeUndefined();\n\n      expect(mockExecFile).toHaveBeenCalledTimes(2);\n      expect(mockExecFile).toHaveBeenNthCalledWith(\n        1,\n        'xdg-open',\n        ['https://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n      expect(mockExecFile).toHaveBeenNthCalledWith(\n        2,\n        'gnome-open',\n        ['https://example.com'],\n        expect.any(Object),\n        expect.any(Function),\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/__tests__/utils/validation.test.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { describe, it, expect } from '@jest/globals';\nimport {\n  validateEmail,\n  validateDateTime,\n  validateDocumentId,\n  extractDocumentId,\n  emailSchema,\n  emailArraySchema,\n  searchQuerySchema,\n  ValidationError,\n} from '../../utils/validation';\n\ndescribe('Validation Utilities', () => {\n  describe('Email Validation', () => {\n    it('should validate correct email addresses', () => {\n      expect(validateEmail('user@example.com')).toEqual({ success: true });\n      expect(validateEmail('john.doe+tag@company.co.uk')).toEqual({\n        success: true,\n      });\n    });\n\n    it('should reject invalid email addresses', () => {\n      expect(validateEmail('invalid')).toMatchObject({ success: false });\n      expect(validateEmail('@example.com')).toMatchObject({ success: false });\n      expect(validateEmail('user@')).toMatchObject({ success: false });\n      expect(validateEmail('user @example.com')).toMatchObject({\n        success: false,\n      });\n    });\n\n    it('should handle email arrays', () => {\n      const result1 = emailSchema.safeParse('user@example.com');\n      expect(result1.success).toBe(true);\n\n      const result2 = emailSchema.safeParse([\n        'user1@example.com',\n        'user2@example.com',\n      ]);\n      expect(result2.success).toBe(false); // Single schema doesn't accept arrays\n    });\n\n    it('should validate emailArraySchema with single email', () => {\n      const result = emailArraySchema.safeParse('user@example.com');\n      expect(result.success).toBe(true);\n    });\n\n    it('should validate emailArraySchema with array of emails', () => {\n      const result = emailArraySchema.safeParse([\n        'user1@example.com',\n        'user2@example.com',\n      ]);\n      expect(result.success).toBe(true);\n    });\n\n    it('should reject emailArraySchema with invalid emails in array', () => {\n      const result = emailArraySchema.safeParse([\n        'valid@example.com',\n        'invalid-email',\n      ]);\n      expect(result.success).toBe(false);\n    });\n  });\n\n  describe('DateTime Validation', () => {\n    it('should validate correct ISO 8601 datetime formats', () => {\n      expect(validateDateTime('2024-01-15T10:30:00Z')).toEqual({\n        success: true,\n      });\n      expect(validateDateTime('2024-01-15T10:30:00.000Z')).toEqual({\n        success: true,\n      });\n      expect(validateDateTime('2024-01-15T10:30:00-05:00')).toEqual({\n        success: true,\n      });\n      expect(validateDateTime('2024-01-15T10:30:00+09:30')).toEqual({\n        success: true,\n      });\n    });\n\n    it('should reject invalid datetime formats', () => {\n      expect(validateDateTime('2024-01-15')).toMatchObject({ success: false });\n      expect(validateDateTime('10:30:00')).toMatchObject({ success: false });\n      expect(validateDateTime('2024-01-15 10:30:00')).toMatchObject({\n        success: false,\n      });\n      expect(validateDateTime('not a date')).toMatchObject({ success: false });\n    });\n\n    it('should reject invalid dates', () => {\n      expect(validateDateTime('2024-13-01T10:30:00Z')).toMatchObject({\n        success: false,\n      }); // Invalid month\n      // Note: JavaScript Date constructor accepts Feb 30 and converts it to March 1st or 2nd\n      // So this test would pass as valid. We'd need more complex validation for this.\n      expect(validateDateTime('2024-00-01T10:30:00Z')).toMatchObject({\n        success: false,\n      }); // Invalid month (0)\n    });\n  });\n\n  describe('Document ID Validation', () => {\n    it('should validate correct document IDs', () => {\n      expect(validateDocumentId('1a2b3c4d5e6f7g8h9i0j')).toEqual({\n        success: true,\n      });\n      expect(validateDocumentId('abc-123_XYZ')).toEqual({ success: true });\n      expect(validateDocumentId('Document_ID-123')).toEqual({ success: true });\n    });\n\n    it('should reject invalid document IDs', () => {\n      expect(validateDocumentId('doc id with spaces')).toMatchObject({\n        success: false,\n      });\n      expect(validateDocumentId('doc#id')).toMatchObject({ success: false });\n      expect(validateDocumentId('doc/id')).toMatchObject({ success: false });\n      expect(validateDocumentId('')).toMatchObject({ success: false });\n    });\n  });\n\n  describe('Document ID Extraction', () => {\n    it('should extract ID from Google Docs URLs', () => {\n      const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f/edit';\n      expect(extractDocumentId(url)).toBe('1a2b3c4d5e6f');\n    });\n\n    it('should extract ID from Google Drive URLs', () => {\n      const url = 'https://drive.google.com/file/d/abc123XYZ/view';\n      expect(extractDocumentId(url)).toBe('abc123XYZ');\n    });\n\n    it('should extract ID from Google Sheets URLs', () => {\n      const url = 'https://sheets.google.com/spreadsheets/d/sheet_id_123/edit';\n      expect(extractDocumentId(url)).toBe('sheet_id_123');\n    });\n\n    it('should return ID if already valid', () => {\n      const id = 'valid_document_id_123';\n      expect(extractDocumentId(id)).toBe(id);\n    });\n\n    it('should throw error for invalid input', () => {\n      expect(() => extractDocumentId('not a valid url or id')).toThrow();\n      expect(() => extractDocumentId('https://example.com/doc')).toThrow();\n    });\n  });\n\n  describe('Search Query Sanitization', () => {\n    it('should escape potentially dangerous characters', () => {\n      const result = searchQuerySchema.parse(\"test' OR '1'='1\");\n      expect(result).toBe(\"test\\\\' OR \\\\'1\\\\'=\\\\'1\"); // Quotes are escaped\n    });\n\n    it('should escape quotes while preserving search functionality', () => {\n      const result = searchQuerySchema.parse('search for \"exact phrase\"');\n      expect(result).toBe('search for \\\\\"exact phrase\\\\\"');\n    });\n\n    it('should preserve safe characters', () => {\n      const result = searchQuerySchema.parse(\n        'test query with spaces and-dashes',\n      );\n      expect(result).toBe('test query with spaces and-dashes');\n    });\n  });\n\n  describe('ValidationError', () => {\n    it('should create proper error with field and value', () => {\n      const error = new ValidationError('Invalid email', 'email', 'bad@');\n      expect(error.message).toBe('Invalid email');\n      expect(error.field).toBe('email');\n      expect(error.value).toBe('bad@');\n      expect(error.name).toBe('ValidationError');\n    });\n  });\n});\n"
  },
  {
    "path": "workspace-server/src/auth/AuthManager.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, Auth } from 'googleapis';\nimport crypto from 'node:crypto';\nimport * as http from 'node:http';\nimport * as net from 'node:net';\nimport * as url from 'node:url';\nimport { logToFile } from '../utils/logger';\nimport open from '../utils/open-wrapper';\nimport { shouldLaunchBrowser } from '../utils/secure-browser-launcher';\nimport { OAuthCredentialStorage } from './token-storage/oauth-credential-storage';\nimport { loadConfig } from '../utils/config';\n\nconst config = loadConfig();\nconst CLIENT_ID = config.clientId;\nconst CLOUD_FUNCTION_URL = config.cloudFunctionUrl;\nconst TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes\n\n/**\n * An Authentication URL for updating the credentials of a Oauth2Client\n * as well as a promise that will resolve when the credentials have\n * been refreshed (or which throws error when refreshing credentials failed).\n */\ninterface OauthWebLogin {\n  authUrl: string;\n  loginCompletePromise: Promise<void>;\n}\n\nexport class AuthManager {\n  private client: Auth.OAuth2Client | null = null;\n  private scopes: string[];\n  private onStatusUpdate: ((message: string) => void) | null = null;\n\n  constructor(scopes: string[]) {\n    this.scopes = scopes;\n  }\n\n  public setOnStatusUpdate(callback: (message: string) => void) {\n    this.onStatusUpdate = callback;\n  }\n\n  private isTokenExpiringSoon(credentials: Auth.Credentials): boolean {\n    return !!(\n      credentials.expiry_date &&\n      credentials.expiry_date < Date.now() + TOKEN_EXPIRY_BUFFER_MS\n    );\n  }\n\n  private async loadCachedCredentials(\n    client: Auth.OAuth2Client,\n  ): Promise<boolean> {\n    const credentials = await OAuthCredentialStorage.loadCredentials();\n\n    if (credentials) {\n      // Check if saved token has required scopes\n      const savedScopes = new Set(credentials.scope?.split(' ') ?? []);\n      logToFile(`Cached token has scopes: ${[...savedScopes].join(', ')}`);\n      logToFile(`Required scopes: ${this.scopes.join(', ')}`);\n\n      const missingScopes = this.scopes.filter(\n        (scope) => !savedScopes.has(scope),\n      );\n\n      if (missingScopes.length > 0) {\n        logToFile(\n          `Token cache missing required scopes: ${missingScopes.join(', ')}`,\n        );\n        logToFile('Removing cached token to force re-authentication...');\n        await OAuthCredentialStorage.clearCredentials();\n        return false;\n      } else {\n        client.setCredentials(credentials);\n        return true;\n      }\n    }\n\n    return false;\n  }\n\n  public async getAuthenticatedClient(): Promise<Auth.OAuth2Client> {\n    logToFile('getAuthenticatedClient called');\n\n    // Check if we have a cached client with valid credentials\n    if (\n      this.client &&\n      this.client.credentials &&\n      this.client.credentials.refresh_token\n    ) {\n      logToFile('Returning existing cached client with valid credentials');\n      logToFile(\n        `Access token exists: ${!!this.client.credentials.access_token}`,\n      );\n      logToFile(`Expiry date: ${this.client.credentials.expiry_date}`);\n      logToFile(`Current time: ${Date.now()}`);\n\n      const isExpired = this.isTokenExpiringSoon(this.client.credentials);\n      logToFile(`Token expired: ${isExpired}`);\n\n      // Proactively refresh if expired\n      if (isExpired) {\n        logToFile('Token is expired, refreshing proactively...');\n        try {\n          await this.refreshToken();\n          logToFile('Token refreshed successfully');\n        } catch (error) {\n          logToFile(`Failed to refresh token: ${error}`);\n          // Clear the client and fall through to re-authenticate\n          this.client = null;\n          await OAuthCredentialStorage.clearCredentials();\n        }\n      }\n\n      // Return the client (either still valid or just refreshed)\n      if (this.client) {\n        return this.client;\n      }\n    }\n\n    // Note: No clientSecret is provided here. The secret is only known by the cloud function.\n    const options: Auth.OAuth2ClientOptions = {\n      clientId: CLIENT_ID,\n    };\n    const oAuth2Client = new google.auth.OAuth2(options);\n\n    oAuth2Client.on('tokens', async (tokens) => {\n      logToFile('Tokens refreshed event received');\n      if (tokens.refresh_token) {\n        logToFile('New refresh token received in event');\n      }\n\n      try {\n        // Create a copy to preserve refresh_token from storage\n        const current = (await OAuthCredentialStorage.loadCredentials()) || {};\n        const merged = {\n          ...tokens,\n          refresh_token: tokens.refresh_token || current.refresh_token,\n        };\n        await OAuthCredentialStorage.saveCredentials(merged);\n        logToFile('Credentials saved after refresh');\n      } catch (e) {\n        logToFile(`Error saving refreshed credentials: ${e}`);\n      }\n    });\n\n    logToFile('No valid cached client, checking for saved credentials...');\n    if (await this.loadCachedCredentials(oAuth2Client)) {\n      logToFile('Loaded saved credentials, caching and returning client');\n      this.client = oAuth2Client;\n\n      // Check if the loaded token is expired and refresh proactively\n      const isExpired = this.isTokenExpiringSoon(this.client.credentials);\n      logToFile(`Token expired: ${isExpired}`);\n\n      if (isExpired) {\n        logToFile('Loaded token is expired, refreshing proactively...');\n        try {\n          await this.refreshToken();\n          logToFile('Token refreshed successfully after loading from storage');\n        } catch (error) {\n          logToFile(`Failed to refresh loaded token: ${error}`);\n          // Clear the client and fall through to re-authenticate\n          this.client = null;\n          await OAuthCredentialStorage.clearCredentials();\n        }\n      }\n\n      // Return the client if refresh succeeded or token was still valid\n      if (this.client) {\n        return this.client;\n      }\n    }\n\n    // Fail fast in headless environments instead of hanging for 5 minutes\n    if (!shouldLaunchBrowser()) {\n      throw new Error(\n        'No browser available for authentication. ' +\n          'Please run: node dist/headless-login.js\\n' +\n          '(from the workspace-server directory)\\n' +\n          'After authenticating, retry your request.',\n      );\n    }\n\n    const webLogin = await this.authWithWeb(oAuth2Client);\n    await open(webLogin.authUrl);\n    const msg = 'Waiting for authentication... Check your browser.';\n    logToFile(msg);\n    if (this.onStatusUpdate) {\n      this.onStatusUpdate(msg);\n    }\n\n    // Add timeout to prevent infinite waiting when browser tab gets stuck\n    const authTimeout = 5 * 60 * 1000; // 5 minutes timeout\n    const timeoutPromise = new Promise<never>((_, reject) => {\n      setTimeout(() => {\n        reject(\n          new Error(\n            'User is not authenticated. Authentication timed out after 5 minutes. The user did not complete the login process in the browser. ' +\n              'Please ask the user to check their browser and try again.',\n          ),\n        );\n      }, authTimeout);\n    });\n    await Promise.race([webLogin.loginCompletePromise, timeoutPromise]);\n\n    await OAuthCredentialStorage.saveCredentials(oAuth2Client.credentials);\n    this.client = oAuth2Client;\n    return this.client;\n  }\n\n  public async clearAuth(): Promise<void> {\n    logToFile('Clearing authentication...');\n    this.client = null;\n    await OAuthCredentialStorage.clearCredentials();\n    logToFile('Authentication cleared.');\n  }\n\n  public async refreshToken(): Promise<void> {\n    logToFile('Manual token refresh triggered');\n    if (!this.client) {\n      logToFile('No client available to refresh, getting new client');\n      this.client = await this.getAuthenticatedClient();\n    }\n    try {\n      const currentCredentials = { ...this.client.credentials };\n\n      if (!currentCredentials.refresh_token) {\n        throw new Error('No refresh token available');\n      }\n\n      logToFile('Calling cloud function to refresh token...');\n\n      // Call the cloud function refresh endpoint\n      // The cloud function has the client secret needed for token refresh\n      const response = await fetch(`${CLOUD_FUNCTION_URL}/refreshToken`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({\n          refresh_token: currentCredentials.refresh_token,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorText = await response.text();\n        throw new Error(\n          `Token refresh failed: ${response.status} ${errorText}`,\n        );\n      }\n\n      const newTokens = await response.json();\n\n      // Merge new tokens with existing credentials, preserving refresh_token\n      // Note: Google does NOT return a new refresh_token on refresh\n      const mergedCredentials = {\n        ...newTokens,\n        refresh_token: currentCredentials.refresh_token, // Always preserve original\n      };\n\n      this.client.setCredentials(mergedCredentials);\n      await OAuthCredentialStorage.saveCredentials(mergedCredentials);\n      logToFile('Token refreshed and saved successfully via cloud function');\n    } catch (error) {\n      logToFile(`Error during token refresh: ${error}`);\n      throw error;\n    }\n  }\n\n  private async getAvailablePort(): Promise<number> {\n    return new Promise((resolve, reject) => {\n      let port = 0;\n      try {\n        const portStr = process.env['OAUTH_CALLBACK_PORT'];\n        if (portStr) {\n          port = parseInt(portStr, 10);\n          if (isNaN(port) || port <= 0 || port > 65535) {\n            return reject(\n              new Error(`Invalid value for OAUTH_CALLBACK_PORT: \"${portStr}\"`),\n            );\n          }\n          return resolve(port);\n        }\n        const server = net.createServer();\n        server.listen(0, () => {\n          const address = server.address()! as net.AddressInfo;\n          port = address.port;\n        });\n        server.on('listening', () => {\n          server.close();\n          server.unref();\n        });\n        server.on('error', (e) => reject(e));\n        server.on('close', () => resolve(port));\n      } catch (e) {\n        reject(e);\n      }\n    });\n  }\n\n  private async authWithWeb(client: Auth.OAuth2Client): Promise<OauthWebLogin> {\n    logToFile(\n      `Requesting authentication with scopes: ${this.scopes.join(', ')}`,\n    );\n\n    const port = await this.getAvailablePort();\n    const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost';\n\n    const localRedirectUri = `http://${host}:${port}/oauth2callback`;\n\n    const isGuiAvailable = shouldLaunchBrowser();\n\n    // SECURITY: Generate a random token for CSRF protection.\n    const csrfToken = crypto.randomBytes(32).toString('hex');\n\n    // The state now contains a JSON payload indicating the flow mode and CSRF token.\n    const statePayload = {\n      uri: isGuiAvailable ? localRedirectUri : undefined,\n      manual: !isGuiAvailable,\n      csrf: csrfToken,\n    };\n    const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');\n\n    // The redirect URI for Google's auth server is the cloud function\n    const cloudFunctionRedirectUri = CLOUD_FUNCTION_URL;\n\n    const authUrl = client.generateAuthUrl({\n      redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function\n      access_type: 'offline',\n      scope: this.scopes,\n      state: state, // Pass our JSON payload in the state\n      prompt: 'consent', // Make sure we get a refresh token\n    });\n\n    const loginCompletePromise = new Promise<void>((resolve, reject) => {\n      const server = http.createServer(async (req, res) => {\n        try {\n          // Use startsWith for more robust path checking.\n          if (!req.url || !req.url.startsWith('/oauth2callback')) {\n            res.end();\n            reject(\n              new Error(\n                'OAuth callback not received. Unexpected request: ' + req.url,\n              ),\n            );\n            return;\n          }\n\n          const qs = new url.URL(req.url, `http://${host}:${port}`)\n            .searchParams;\n\n          // SECURITY: Validate the state parameter to prevent CSRF attacks.\n          const returnedState = qs.get('state');\n          if (returnedState !== csrfToken) {\n            res.end('State mismatch. Possible CSRF attack.');\n            reject(new Error('OAuth state mismatch. Possible CSRF attack.'));\n            return;\n          }\n\n          if (qs.get('error')) {\n            const errorCode = qs.get('error');\n            const errorDescription =\n              qs.get('error_description') || 'No additional details provided';\n            res.end();\n            reject(\n              new Error(\n                `Google OAuth error: ${errorCode}. ${errorDescription}`,\n              ),\n            );\n            return;\n          }\n\n          const access_token = qs.get('access_token');\n          const refresh_token = qs.get('refresh_token');\n          const scope = qs.get('scope');\n          const token_type = qs.get('token_type');\n          const expiry_date_str = qs.get('expiry_date');\n\n          if (access_token && expiry_date_str) {\n            const tokens: Auth.Credentials = {\n              access_token: access_token,\n              refresh_token: refresh_token || null,\n              scope: scope || undefined,\n              token_type: (token_type as 'Bearer') || undefined,\n              expiry_date: parseInt(expiry_date_str, 10),\n            };\n            client.setCredentials(tokens);\n            res.end('Authentication successful! Please return to the console.');\n            resolve();\n          } else {\n            reject(\n              new Error(\n                'Authentication failed: Did not receive tokens from callback.',\n              ),\n            );\n          }\n        } catch (e) {\n          reject(e);\n        } finally {\n          server.close();\n        }\n      });\n\n      server.listen(port, host, () => {\n        // Server started successfully\n      });\n\n      server.on('error', (err) => {\n        reject(new Error(`OAuth callback server error: ${err}`));\n      });\n    });\n\n    return {\n      authUrl,\n      loginCompletePromise,\n    };\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/scopes.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { resolveFeatures } from '../features/index';\n\n/**\n * OAuth scopes required by the Google Workspace MCP server.\n *\n * Dynamically computed from the feature configuration registry,\n * respecting WORKSPACE_FEATURE_OVERRIDES and default states.\n *\n * Shared between the MCP server and the headless login CLI.\n */\nexport const SCOPES: string[] = resolveFeatures(\n  undefined,\n  process.env['WORKSPACE_FEATURE_OVERRIDES'],\n).requiredScopes;\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/base-token-storage.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { OAuthCredentials, TokenStorage } from './types';\n\nexport abstract class BaseTokenStorage implements TokenStorage {\n  protected readonly serviceName: string;\n\n  constructor(serviceName: string) {\n    this.serviceName = serviceName;\n  }\n\n  abstract getCredentials(serverName: string): Promise<OAuthCredentials | null>;\n  abstract setCredentials(credentials: OAuthCredentials): Promise<void>;\n  abstract deleteCredentials(serverName: string): Promise<void>;\n  abstract listServers(): Promise<string[]>;\n  abstract getAllCredentials(): Promise<Map<string, OAuthCredentials>>;\n  abstract clearAll(): Promise<void>;\n\n  protected validateCredentials(credentials: OAuthCredentials): void {\n    if (!credentials.serverName) {\n      throw new Error('Server name is required');\n    }\n    if (!credentials.token) {\n      throw new Error('Token is required');\n    }\n    if (!credentials.token.accessToken && !credentials.token.refreshToken) {\n      throw new Error('Access token or refresh token is required');\n    }\n    if (!credentials.token.tokenType) {\n      throw new Error('Token type is required');\n    }\n  }\n\n  protected sanitizeServerName(serverName: string): string {\n    return serverName.replace(/[^a-zA-Z0-9-_.]/g, '_');\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/file-token-storage.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { promises as fs } from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport * as crypto from 'node:crypto';\nimport { BaseTokenStorage } from './base-token-storage';\nimport type { OAuthCredentials } from './types';\nimport { logToFile } from '../../utils/logger';\nimport {\n  ENCRYPTED_TOKEN_PATH,\n  ENCRYPTION_MASTER_KEY_PATH,\n} from '../../utils/paths';\n\nexport class FileTokenStorage extends BaseTokenStorage {\n  private readonly tokenFilePath: string;\n  private readonly encryptionKey: Buffer;\n  private readonly masterKey: Buffer;\n\n  private constructor(serviceName: string, masterKey: Buffer) {\n    super(serviceName);\n    this.tokenFilePath = ENCRYPTED_TOKEN_PATH;\n    this.masterKey = masterKey;\n    this.encryptionKey = this.deriveEncryptionKey();\n  }\n\n  static async create(serviceName: string): Promise<FileTokenStorage> {\n    const masterKey = await this.loadMasterKey();\n    return new FileTokenStorage(serviceName, masterKey);\n  }\n\n  private static async loadMasterKey(): Promise<Buffer> {\n    try {\n      const masterKey = await fs.readFile(ENCRYPTION_MASTER_KEY_PATH);\n      return masterKey;\n    } catch (error) {\n      const err = error as NodeJS.ErrnoException;\n      if (err.code === 'ENOENT') {\n        const newKey = crypto.randomBytes(32);\n        await fs.writeFile(ENCRYPTION_MASTER_KEY_PATH, newKey, { mode: 0o600 });\n        return newKey;\n      }\n      throw error;\n    }\n  }\n\n  private deriveEncryptionKey(): Buffer {\n    const salt = `${os.hostname()}-${\n      os.userInfo().username\n    }-gemini-cli-workspace`;\n    return crypto.scryptSync(this.masterKey, salt, 32);\n  }\n\n  private encrypt(text: string): string {\n    const iv = crypto.randomBytes(16);\n    const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);\n\n    let encrypted = cipher.update(text, 'utf8', 'hex');\n    encrypted += cipher.final('hex');\n\n    const authTag = cipher.getAuthTag();\n\n    return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;\n  }\n\n  private decrypt(encryptedData: string): string {\n    const parts = encryptedData.split(':');\n    if (parts.length !== 3) {\n      throw new Error('Invalid encrypted data format');\n    }\n\n    const iv = Buffer.from(parts[0], 'hex');\n    const authTag = Buffer.from(parts[1], 'hex');\n    const encrypted = parts[2];\n\n    const decipher = crypto.createDecipheriv(\n      'aes-256-gcm',\n      this.encryptionKey,\n      iv,\n    );\n    decipher.setAuthTag(authTag);\n\n    let decrypted = decipher.update(encrypted, 'hex', 'utf8');\n    decrypted += decipher.final('utf8');\n\n    return decrypted;\n  }\n\n  private async ensureDirectoryExists(): Promise<void> {\n    const dir = path.dirname(this.tokenFilePath);\n    await fs.mkdir(dir, { recursive: true, mode: 0o700 });\n  }\n\n  private async loadTokens(): Promise<Map<string, OAuthCredentials>> {\n    try {\n      const data = await fs.readFile(this.tokenFilePath, 'utf-8');\n      const decrypted = this.decrypt(data);\n      const tokens = JSON.parse(decrypted) as Record<string, OAuthCredentials>;\n      return new Map(Object.entries(tokens));\n    } catch (error: unknown) {\n      const err = error as NodeJS.ErrnoException & { message?: string };\n      if (err.code === 'ENOENT') {\n        logToFile('Token file does not exist');\n        return new Map<string, OAuthCredentials>();\n      }\n      if (\n        err.message?.includes('Invalid encrypted data format') ||\n        err.message?.includes(\n          'Unsupported state or unable to authenticate data',\n        )\n      ) {\n        logToFile('Token file corrupted');\n        return new Map<string, OAuthCredentials>();\n      }\n      throw error;\n    }\n  }\n\n  private async saveTokens(\n    tokens: Map<string, OAuthCredentials>,\n  ): Promise<void> {\n    await this.ensureDirectoryExists();\n\n    const data = Object.fromEntries(tokens);\n    const json = JSON.stringify(data, null, 2);\n    const encrypted = this.encrypt(json);\n\n    await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });\n  }\n\n  async getCredentials(serverName: string): Promise<OAuthCredentials | null> {\n    const tokens = await this.loadTokens();\n    const credentials = tokens.get(serverName);\n\n    if (!credentials) {\n      return null;\n    }\n\n    return credentials;\n  }\n\n  async setCredentials(credentials: OAuthCredentials): Promise<void> {\n    this.validateCredentials(credentials);\n\n    const tokens = await this.loadTokens();\n    const updatedCredentials: OAuthCredentials = {\n      ...credentials,\n      updatedAt: Date.now(),\n    };\n\n    tokens.set(credentials.serverName, updatedCredentials);\n    await this.saveTokens(tokens);\n  }\n\n  async deleteCredentials(serverName: string): Promise<void> {\n    const tokens = await this.loadTokens();\n\n    if (!tokens.has(serverName)) {\n      throw new Error(`No credentials found for ${serverName}`);\n    }\n\n    tokens.delete(serverName);\n\n    if (tokens.size === 0) {\n      try {\n        await fs.unlink(this.tokenFilePath);\n      } catch (error: unknown) {\n        const err = error as NodeJS.ErrnoException;\n        if (err.code !== 'ENOENT') {\n          throw error;\n        }\n      }\n    } else {\n      await this.saveTokens(tokens);\n    }\n  }\n\n  async listServers(): Promise<string[]> {\n    const tokens = await this.loadTokens();\n    return Array.from(tokens.keys());\n  }\n\n  async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {\n    const tokens = await this.loadTokens();\n    const result = new Map<string, OAuthCredentials>();\n\n    for (const [serverName, credentials] of tokens) {\n      try {\n        this.validateCredentials(credentials);\n        result.set(serverName, credentials);\n      } catch (error) {\n        console.error(`Skipping invalid credentials for ${serverName}:`, error);\n      }\n    }\n\n    return result;\n  }\n\n  async clearAll(): Promise<void> {\n    try {\n      await fs.unlink(this.tokenFilePath);\n    } catch (error: unknown) {\n      const err = error as NodeJS.ErrnoException;\n      if (err.code !== 'ENOENT') {\n        throw error;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/hybrid-token-storage.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { BaseTokenStorage } from './base-token-storage';\nimport { FileTokenStorage } from './file-token-storage';\nimport type { TokenStorage, OAuthCredentials } from './types';\nimport { TokenStorageType } from './types';\n\nconst FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE';\n\nexport class HybridTokenStorage extends BaseTokenStorage {\n  private storage: TokenStorage | null = null;\n  private storageType: TokenStorageType | null = null;\n  private storageInitPromise: Promise<TokenStorage> | null = null;\n\n  constructor(serviceName: string) {\n    super(serviceName);\n  }\n\n  private async initializeStorage(): Promise<TokenStorage> {\n    const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';\n\n    if (!forceFileStorage) {\n      try {\n        const { KeychainTokenStorage } =\n          await import('./keychain-token-storage');\n        const keychainStorage = new KeychainTokenStorage(this.serviceName);\n\n        const isAvailable = await keychainStorage.isAvailable();\n        if (isAvailable) {\n          this.storage = keychainStorage;\n          this.storageType = TokenStorageType.KEYCHAIN;\n          return this.storage;\n        }\n      } catch (e) {\n        // Fallback to file storage if keychain fails to initialize.\n        console.warn(\n          'Keychain initialization failed, falling back to file storage:',\n          e,\n        );\n      }\n    }\n\n    this.storage = await FileTokenStorage.create(this.serviceName);\n    this.storageType = TokenStorageType.ENCRYPTED_FILE;\n    return this.storage;\n  }\n\n  private async getStorage(): Promise<TokenStorage> {\n    if (this.storage !== null) {\n      return this.storage;\n    }\n\n    // Use a single initialization promise to avoid race conditions\n    if (!this.storageInitPromise) {\n      this.storageInitPromise = this.initializeStorage();\n    }\n\n    // Wait for initialization to complete\n    return await this.storageInitPromise;\n  }\n\n  async getCredentials(serverName: string): Promise<OAuthCredentials | null> {\n    const storage = await this.getStorage();\n    return storage.getCredentials(serverName);\n  }\n\n  async setCredentials(credentials: OAuthCredentials): Promise<void> {\n    const storage = await this.getStorage();\n    await storage.setCredentials(credentials);\n  }\n\n  async deleteCredentials(serverName: string): Promise<void> {\n    const storage = await this.getStorage();\n    await storage.deleteCredentials(serverName);\n  }\n\n  async listServers(): Promise<string[]> {\n    const storage = await this.getStorage();\n    return storage.listServers();\n  }\n\n  async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {\n    const storage = await this.getStorage();\n    return storage.getAllCredentials();\n  }\n\n  async clearAll(): Promise<void> {\n    const storage = await this.getStorage();\n    await storage.clearAll();\n  }\n\n  async getStorageType(): Promise<TokenStorageType> {\n    await this.getStorage();\n    return this.storageType!;\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/index.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport * from './types';\nexport * from './base-token-storage';\nexport * from './file-token-storage';\nexport * from './hybrid-token-storage';\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/keychain-token-storage.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport * as crypto from 'node:crypto';\nimport { BaseTokenStorage } from './base-token-storage';\nimport type { OAuthCredentials } from './types';\n\ninterface Keytar {\n  getPassword(service: string, account: string): Promise<string | null>;\n  setPassword(\n    service: string,\n    account: string,\n    password: string,\n  ): Promise<void>;\n  deletePassword(service: string, account: string): Promise<boolean>;\n  findCredentials(\n    service: string,\n  ): Promise<Array<{ account: string; password: string }>>;\n}\n\nconst KEYCHAIN_TEST_PREFIX = '__keychain_test__';\n\nexport class KeychainTokenStorage extends BaseTokenStorage {\n  private keychainAvailable: boolean | null = null;\n  private keytarModule: Keytar | null = null;\n  private keytarLoadAttempted = false;\n\n  async getKeytar(): Promise<Keytar | null> {\n    // If we've already tried loading (successfully or not), return the result\n    if (this.keytarLoadAttempted) {\n      return this.keytarModule;\n    }\n\n    this.keytarLoadAttempted = true;\n\n    try {\n      // Try to import keytar without any timeout - let the OS handle it\n      const moduleName = 'keytar';\n      const module = await import(moduleName);\n      this.keytarModule = module.default || module;\n    } catch (error) {\n      console.error(error);\n    }\n    return this.keytarModule;\n  }\n\n  async getCredentials(serverName: string): Promise<OAuthCredentials | null> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const keytar = await this.getKeytar();\n    if (!keytar) {\n      throw new Error('Keytar module not available');\n    }\n\n    try {\n      const sanitizedName = this.sanitizeServerName(serverName);\n      const data = await keytar.getPassword(this.serviceName, sanitizedName);\n\n      if (!data) {\n        return null;\n      }\n\n      const credentials = JSON.parse(data) as OAuthCredentials;\n\n      return credentials;\n    } catch (error) {\n      if (error instanceof SyntaxError) {\n        throw new Error(`Failed to parse stored credentials for ${serverName}`);\n      }\n      throw error;\n    }\n  }\n\n  async setCredentials(credentials: OAuthCredentials): Promise<void> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const keytar = await this.getKeytar();\n    if (!keytar) {\n      throw new Error('Keytar module not available');\n    }\n\n    this.validateCredentials(credentials);\n\n    const sanitizedName = this.sanitizeServerName(credentials.serverName);\n    const updatedCredentials: OAuthCredentials = {\n      ...credentials,\n      updatedAt: Date.now(),\n    };\n\n    const data = JSON.stringify(updatedCredentials);\n    await keytar.setPassword(this.serviceName, sanitizedName, data);\n  }\n\n  async deleteCredentials(serverName: string): Promise<void> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const keytar = await this.getKeytar();\n    if (!keytar) {\n      throw new Error('Keytar module not available');\n    }\n\n    const sanitizedName = this.sanitizeServerName(serverName);\n    const deleted = await keytar.deletePassword(\n      this.serviceName,\n      sanitizedName,\n    );\n\n    if (!deleted) {\n      throw new Error(`No credentials found for ${serverName}`);\n    }\n  }\n\n  async listServers(): Promise<string[]> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const keytar = await this.getKeytar();\n    if (!keytar) {\n      throw new Error('Keytar module not available');\n    }\n\n    try {\n      const credentials = await keytar.findCredentials(this.serviceName);\n      return credentials\n        .filter((cred) => !cred.account.startsWith(KEYCHAIN_TEST_PREFIX))\n        .map((cred: { account: string }) => cred.account);\n    } catch (error) {\n      console.error('Failed to list servers from keychain:', error);\n      return [];\n    }\n  }\n\n  async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const keytar = await this.getKeytar();\n    if (!keytar) {\n      throw new Error('Keytar module not available');\n    }\n\n    const result = new Map<string, OAuthCredentials>();\n    try {\n      const credentials = (\n        await keytar.findCredentials(this.serviceName)\n      ).filter((c) => !c.account.startsWith(KEYCHAIN_TEST_PREFIX));\n\n      for (const cred of credentials) {\n        try {\n          const data = JSON.parse(cred.password) as OAuthCredentials;\n          this.validateCredentials(data);\n          result.set(cred.account, data);\n        } catch (error) {\n          console.error(\n            `Failed to parse credentials for ${cred.account}:`,\n            error,\n          );\n        }\n      }\n    } catch (error) {\n      console.error('Failed to get all credentials from keychain:', error);\n    }\n\n    return result;\n  }\n\n  async clearAll(): Promise<void> {\n    if (!(await this.checkKeychainAvailability())) {\n      throw new Error('Keychain is not available');\n    }\n\n    const servers = this.keytarModule\n      ? await this.keytarModule\n          .findCredentials(this.serviceName)\n          .then((creds) => creds.map((c) => c.account))\n          .catch((error: Error) => {\n            throw new Error(\n              `Failed to list servers for clearing: ${error.message}`,\n            );\n          })\n      : [];\n    const errors: Error[] = [];\n\n    for (const server of servers) {\n      try {\n        await this.deleteCredentials(server);\n      } catch (error) {\n        errors.push(error as Error);\n      }\n    }\n\n    if (errors.length > 0) {\n      throw new Error(\n        `Failed to clear some credentials: ${errors.map((e) => e.message).join(', ')}`,\n      );\n    }\n  }\n\n  // Checks whether or not a set-get-delete cycle with the keychain works.\n  // Returns false if any operation fails.\n  async checkKeychainAvailability(): Promise<boolean> {\n    if (this.keychainAvailable !== null) {\n      return this.keychainAvailable;\n    }\n\n    try {\n      const keytar = await this.getKeytar();\n      if (!keytar) {\n        this.keychainAvailable = false;\n        return false;\n      }\n\n      const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;\n      const testPassword = 'test';\n\n      await keytar.setPassword(this.serviceName, testAccount, testPassword);\n      const retrieved = await keytar.getPassword(this.serviceName, testAccount);\n      const deleted = await keytar.deletePassword(\n        this.serviceName,\n        testAccount,\n      );\n\n      const success = deleted && retrieved === testPassword;\n      this.keychainAvailable = success;\n      return success;\n    } catch (_error) {\n      this.keychainAvailable = false;\n      return false;\n    }\n  }\n\n  async isAvailable(): Promise<boolean> {\n    return this.checkKeychainAvailability();\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/oauth-credential-storage.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { type Credentials } from 'google-auth-library';\nimport { HybridTokenStorage } from './hybrid-token-storage';\nimport type { OAuthCredentials } from './types';\n\nconst KEYCHAIN_SERVICE_NAME = 'gemini-cli-workspace-oauth';\nconst MAIN_ACCOUNT_KEY = 'main-account';\n\nexport class OAuthCredentialStorage {\n  private static storage: HybridTokenStorage = new HybridTokenStorage(\n    KEYCHAIN_SERVICE_NAME,\n  );\n\n  /**\n   * Load cached OAuth credentials\n   */\n  static async loadCredentials(): Promise<Credentials | null> {\n    try {\n      const credentials = await this.storage.getCredentials(MAIN_ACCOUNT_KEY);\n\n      if (credentials?.token) {\n        const { accessToken, refreshToken, expiresAt, tokenType, scope } =\n          credentials.token;\n        // Convert from OAuthCredentials format to Google Credentials format\n        const googleCreds: Credentials = {\n          access_token: accessToken,\n          refresh_token: refreshToken || undefined,\n          token_type: tokenType || undefined,\n          scope: scope || undefined,\n        };\n\n        if (expiresAt) {\n          googleCreds.expiry_date = expiresAt;\n        }\n\n        return googleCreds;\n      }\n\n      return null;\n    } catch (error: unknown) {\n      throw error;\n    }\n  }\n\n  /**\n   * Save OAuth credentials\n   */\n  static async saveCredentials(credentials: Credentials): Promise<void> {\n    // Convert Google Credentials to OAuthCredentials format\n    const mcpCredentials: OAuthCredentials = {\n      serverName: MAIN_ACCOUNT_KEY,\n      token: {\n        accessToken: credentials.access_token || undefined,\n        refreshToken: credentials.refresh_token || undefined,\n        tokenType: credentials.token_type || 'Bearer',\n        scope: credentials.scope || undefined,\n        expiresAt: credentials.expiry_date || undefined,\n      },\n      updatedAt: Date.now(),\n    };\n\n    await this.storage.setCredentials(mcpCredentials);\n  }\n\n  /**\n   * Clear cached OAuth credentials\n   */\n  static async clearCredentials(): Promise<void> {\n    try {\n      await this.storage.deleteCredentials(MAIN_ACCOUNT_KEY);\n    } catch (error: unknown) {\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/auth/token-storage/types.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Interface for OAuth tokens.\n */\nexport interface OAuthToken {\n  accessToken?: string;\n  refreshToken?: string;\n  expiresAt?: number;\n  tokenType: string;\n  scope?: string;\n}\n\n/**\n * Interface for stored OAuth credentials.\n */\nexport interface OAuthCredentials {\n  serverName: string;\n  token: OAuthToken;\n  clientId?: string;\n  tokenUrl?: string;\n  mcpServerUrl?: string;\n  updatedAt: number;\n}\n\nexport enum TokenStorageType {\n  KEYCHAIN = 'keychain',\n  ENCRYPTED_FILE = 'encrypted_file',\n}\n\nexport interface TokenStorage {\n  getCredentials(serverName: string): Promise<OAuthCredentials | null>;\n  setCredentials(credentials: OAuthCredentials): Promise<void>;\n  deleteCredentials(serverName: string): Promise<void>;\n  listServers(): Promise<string[]>;\n  getAllCredentials(): Promise<Map<string, OAuthCredentials>>;\n  clearAll(): Promise<void>;\n}\n"
  },
  {
    "path": "workspace-server/src/cli/headless-login.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Headless OAuth login CLI tool.\n *\n * Allows users in headless environments (SSH, WSL, Cloud Shell, VMs) to\n * complete the OAuth flow by:\n * 1. Printing an OAuth URL to open in any browser\n * 2. Reading pasted credentials JSON securely from /dev/tty (not stdin)\n * 3. Saving credentials via OAuthCredentialStorage\n *\n * The /dev/tty approach ensures credentials are never visible to an AI model\n * that may have spawned this process.\n */\n\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as readline from 'node:readline';\nimport crypto from 'node:crypto';\nimport { google } from 'googleapis';\nimport { OAuthCredentialStorage } from '../auth/token-storage/oauth-credential-storage';\nimport { SCOPES } from '../auth/scopes';\nimport { loadConfig } from '../utils/config';\n\nconst config = loadConfig();\nconst CLIENT_ID = config.clientId;\nconst CLOUD_FUNCTION_URL = config.cloudFunctionUrl;\n\ninterface CredentialsJson {\n  access_token: string;\n  refresh_token: string;\n  expiry_date: number;\n  scope?: string;\n  token_type?: string;\n}\n\nconst TTY_PATH = os.platform() === 'win32' ? '\\\\\\\\.\\\\CON' : '/dev/tty';\n\n/**\n * Opens a readable stream from /dev/tty (Unix) or CON (Windows).\n * This bypasses process stdin entirely so credentials can't be intercepted\n * by a parent process.\n */\nfunction openTtyRead(): fs.ReadStream {\n  return fs.createReadStream(TTY_PATH, { encoding: 'utf8' });\n}\n\n/**\n * Opens a writable stream to /dev/tty (Unix) or CON (Windows).\n */\nfunction openTtyWrite(): fs.WriteStream {\n  return fs.createWriteStream(TTY_PATH);\n}\n\n/**\n * Reads multi-line input from /dev/tty until valid JSON is detected\n * or the user presses Enter on an empty line.\n */\nfunction readCredentialsFromTty(): Promise<string> {\n  return new Promise((resolve, reject) => {\n    let input: fs.ReadStream;\n    let output: fs.WriteStream;\n\n    try {\n      input = openTtyRead();\n      output = openTtyWrite();\n    } catch {\n      reject(\n        new Error(\n          'Cannot open terminal for secure input. ' +\n            'This command must be run in an interactive terminal.',\n        ),\n      );\n      return;\n    }\n\n    const rl = readline.createInterface({ input, output, terminal: false });\n    const lines: string[] = [];\n\n    output.write(\n      'Paste the credentials JSON from the browser, then press Enter twice:\\n',\n    );\n\n    rl.on('line', (line) => {\n      lines.push(line);\n\n      // Try to parse accumulated input as JSON after each line\n      const joined = lines.join('\\n').trim();\n      if (joined) {\n        try {\n          JSON.parse(joined);\n          // Valid JSON detected, we're done\n          rl.close();\n          return;\n        } catch {\n          // Not valid JSON yet, keep collecting\n        }\n      }\n\n      // Empty line after content means user is done\n      if (line.trim() === '' && lines.length > 1) {\n        rl.close();\n      }\n    });\n\n    rl.on('close', () => {\n      input.destroy();\n      output.end();\n      resolve(lines.join('\\n').trim());\n    });\n\n    rl.on('error', (err) => {\n      input.destroy();\n      output.end();\n      reject(err);\n    });\n  });\n}\n\n/**\n * Validates that the parsed JSON contains the required credential fields.\n */\nfunction validateCredentials(\n  data: Record<string, unknown>,\n): asserts data is Record<string, unknown> & CredentialsJson {\n  if (typeof data.access_token !== 'string' || !data.access_token) {\n    throw new Error('Missing or invalid \"access_token\" field.');\n  }\n  if (typeof data.refresh_token !== 'string' || !data.refresh_token) {\n    throw new Error('Missing or invalid \"refresh_token\" field.');\n  }\n  if (typeof data.expiry_date !== 'number' || !data.expiry_date) {\n    throw new Error('Missing or invalid \"expiry_date\" field.');\n  }\n}\n\n/**\n * Generates the OAuth URL with manual=true state, matching the pattern\n * used by AuthManager.authWithWeb().\n */\nfunction generateOAuthUrl(): string {\n  const csrfToken = crypto.randomBytes(32).toString('hex');\n\n  const statePayload = {\n    manual: true,\n    csrf: csrfToken,\n  };\n  const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');\n\n  const oAuth2Client = new google.auth.OAuth2({ clientId: CLIENT_ID });\n\n  return oAuth2Client.generateAuthUrl({\n    redirect_uri: CLOUD_FUNCTION_URL,\n    access_type: 'offline',\n    scope: SCOPES,\n    state,\n    prompt: 'consent',\n  });\n}\n\nasync function main() {\n  const force = process.argv.includes('--force');\n\n  // Check for existing credentials unless --force is used\n  if (!force) {\n    const existing = await OAuthCredentialStorage.loadCredentials();\n    if (existing && existing.refresh_token) {\n      console.log('Already authenticated. Credentials found in storage.');\n      console.log('Use --force to re-authenticate.');\n      return;\n    }\n  }\n\n  // Generate and display the OAuth URL\n  const authUrl = generateOAuthUrl();\n\n  console.log();\n  console.log('=== Google Workspace MCP Server - Headless Login ===');\n  console.log();\n  console.log('Open this URL in any browser (local machine, phone, etc.):');\n  console.log();\n  console.log(authUrl);\n  console.log();\n  console.log('After signing in, the browser will show your credentials JSON.');\n  console.log('Copy that JSON and paste it below.');\n  console.log();\n\n  // Read credentials securely from /dev/tty\n  const rawInput = await readCredentialsFromTty();\n\n  if (!rawInput) {\n    console.error('No input received.');\n    process.exit(1);\n  }\n\n  // Parse and validate\n  let parsed: Record<string, unknown>;\n  try {\n    parsed = JSON.parse(rawInput);\n  } catch {\n    console.error(\n      'Invalid JSON. Please copy the complete JSON from the browser.',\n    );\n    process.exit(1);\n  }\n\n  try {\n    validateCredentials(parsed);\n  } catch (err) {\n    console.error(\n      `Invalid credentials: ${err instanceof Error ? err.message : err}`,\n    );\n    process.exit(1);\n  }\n\n  // Save credentials\n  await OAuthCredentialStorage.saveCredentials({\n    access_token: parsed.access_token as string,\n    refresh_token: parsed.refresh_token as string,\n    expiry_date: parsed.expiry_date as number,\n    scope: (parsed.scope as string) || SCOPES.join(' '),\n    token_type: (parsed.token_type as string) || 'Bearer',\n  });\n\n  console.log();\n  console.log('Credentials saved successfully!');\n  console.log('You can now start the MCP server.');\n}\n\nmain().catch((error) => {\n  console.error('Login failed:', error.message || error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "workspace-server/src/features/feature-config.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Feature Configuration Registry\n *\n * Defines read/write feature groups for each Google Workspace service.\n * Each group specifies:\n * - The OAuth scopes it requires\n * - The tools it contains\n * - Whether it's enabled by default\n *\n * Services whose write scopes aren't in the published GCP project\n * (slides.write, sheets.write, tasks) default to OFF.\n */\n\nconst SCOPE_PREFIX = 'https://www.googleapis.com/auth/';\n\nfunction scopes(...names: string[]): string[] {\n  return names.map((name) => `${SCOPE_PREFIX}${name}`);\n}\n\nexport type ServiceName =\n  | 'docs'\n  | 'drive'\n  | 'calendar'\n  | 'chat'\n  | 'gmail'\n  | 'people'\n  | 'slides'\n  | 'sheets'\n  | 'time'\n  | 'tasks';\n\nexport interface FeatureGroup {\n  /** Service name (e.g., 'docs', 'gmail') */\n  readonly service: ServiceName;\n  /** Group type: read (no side effects) or write (mutations) */\n  readonly group: 'read' | 'write';\n  /** OAuth scopes required by this feature group */\n  readonly scopes: readonly string[];\n  /** Tool names belonging to this feature group */\n  readonly tools: readonly string[];\n  /** Whether this feature group is enabled by default */\n  readonly defaultEnabled: boolean;\n}\n\n/**\n * Canonical feature group key, e.g. \"docs.read\", \"gmail.write\"\n */\nexport function featureGroupKey(fg: FeatureGroup): string {\n  return `${fg.service}.${fg.group}`;\n}\n\nexport const FEATURE_GROUPS: readonly FeatureGroup[] = [\n  // Docs\n  {\n    service: 'docs',\n    group: 'read',\n    scopes: scopes('documents'),\n    tools: ['docs.getSuggestions', 'docs.getText'],\n    defaultEnabled: true,\n  },\n  {\n    service: 'docs',\n    group: 'write',\n    scopes: scopes('documents'),\n    tools: [\n      'docs.create',\n      'docs.writeText',\n      'docs.replaceText',\n      'docs.formatText',\n    ],\n    defaultEnabled: true,\n  },\n\n  // Drive\n  {\n    service: 'drive',\n    group: 'read',\n    scopes: scopes('drive.readonly'),\n    tools: [\n      'drive.getComments',\n      'drive.findFolder',\n      'drive.search',\n      'drive.downloadFile',\n    ],\n    defaultEnabled: true,\n  },\n  {\n    service: 'drive',\n    group: 'write',\n    scopes: scopes('drive'),\n    tools: [\n      'drive.createFolder',\n      'drive.moveFile',\n      'drive.trashFile',\n      'drive.renameFile',\n    ],\n    defaultEnabled: true,\n  },\n\n  // Calendar\n  {\n    service: 'calendar',\n    group: 'read',\n    scopes: scopes('calendar.readonly'),\n    tools: [\n      'calendar.list',\n      'calendar.listEvents',\n      'calendar.getEvent',\n      'calendar.findFreeTime',\n    ],\n    defaultEnabled: true,\n  },\n  {\n    service: 'calendar',\n    group: 'write',\n    scopes: scopes('calendar'),\n    tools: [\n      'calendar.createEvent',\n      'calendar.updateEvent',\n      'calendar.respondToEvent',\n      'calendar.deleteEvent',\n    ],\n    defaultEnabled: true,\n  },\n\n  // Chat\n  {\n    service: 'chat',\n    group: 'read',\n    scopes: scopes(\n      'chat.spaces.readonly',\n      'chat.messages.readonly',\n      'chat.memberships.readonly',\n    ),\n    tools: [\n      'chat.listSpaces',\n      'chat.findSpaceByName',\n      'chat.getMessages',\n      'chat.findDmByEmail',\n      'chat.listThreads',\n    ],\n    defaultEnabled: true,\n  },\n  {\n    service: 'chat',\n    group: 'write',\n    scopes: scopes('chat.spaces', 'chat.messages', 'chat.memberships'),\n    tools: ['chat.sendMessage', 'chat.sendDm', 'chat.setUpSpace'],\n    defaultEnabled: true,\n  },\n\n  // Gmail\n  {\n    service: 'gmail',\n    group: 'read',\n    scopes: scopes('gmail.readonly'),\n    tools: [\n      'gmail.search',\n      'gmail.get',\n      'gmail.downloadAttachment',\n      'gmail.listLabels',\n    ],\n    defaultEnabled: true,\n  },\n  {\n    service: 'gmail',\n    group: 'write',\n    scopes: scopes('gmail.modify'),\n    tools: [\n      'gmail.modify',\n      'gmail.batchModify',\n      'gmail.modifyThread',\n      'gmail.send',\n      'gmail.createDraft',\n      'gmail.sendDraft',\n      'gmail.createLabel',\n    ],\n    defaultEnabled: true,\n  },\n\n  // People\n  {\n    service: 'people',\n    group: 'read',\n    scopes: scopes('userinfo.profile', 'directory.readonly'),\n    tools: ['people.getUserProfile', 'people.getMe', 'people.getUserRelations'],\n    defaultEnabled: true,\n  },\n\n  // Slides\n  {\n    service: 'slides',\n    group: 'read',\n    scopes: scopes('presentations.readonly'),\n    tools: [\n      'slides.getText',\n      'slides.getMetadata',\n      'slides.getImages',\n      'slides.getSlideThumbnail',\n    ],\n    defaultEnabled: true,\n  },\n  {\n    service: 'slides',\n    group: 'write',\n    scopes: scopes('presentations'),\n    tools: [],\n    defaultEnabled: false,\n  },\n\n  // Sheets\n  {\n    service: 'sheets',\n    group: 'read',\n    scopes: scopes('spreadsheets.readonly'),\n    tools: ['sheets.getText', 'sheets.getRange', 'sheets.getMetadata'],\n    defaultEnabled: true,\n  },\n  {\n    service: 'sheets',\n    group: 'write',\n    scopes: scopes('spreadsheets'),\n    tools: [],\n    defaultEnabled: false,\n  },\n\n  // Time (no scopes needed)\n  {\n    service: 'time',\n    group: 'read',\n    scopes: [],\n    tools: ['time.getCurrentDate', 'time.getCurrentTime', 'time.getTimeZone'],\n    defaultEnabled: true,\n  },\n\n  // Tasks (experimental — not in published GCP project)\n  {\n    service: 'tasks',\n    group: 'read',\n    scopes: scopes('tasks.readonly'),\n    tools: [],\n    defaultEnabled: false,\n  },\n  {\n    service: 'tasks',\n    group: 'write',\n    scopes: scopes('tasks'),\n    tools: [],\n    defaultEnabled: false,\n  },\n] as const satisfies readonly FeatureGroup[];\n\n/**\n * Every scope that any default-enabled feature group could request.\n *\n * This is the registration list for the OAuth consent screen — broader than\n * the runtime request set, because users can disable individual write groups\n * via WORKSPACE_FEATURE_OVERRIDES, which causes the paired read group's\n * `.readonly` scope to be requested. Both must already be registered, or\n * unverified apps hit \"This app is blocked.\"\n *\n * Returned sorted for stable diffs in `setup-gcp.sh`.\n */\nexport function getAllPossibleScopes(): string[] {\n  const set = new Set<string>();\n  for (const fg of FEATURE_GROUPS) {\n    if (!fg.defaultEnabled) continue;\n    for (const scope of fg.scopes) set.add(scope);\n  }\n  return [...set].sort();\n}\n"
  },
  {
    "path": "workspace-server/src/features/feature-resolver.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Feature Resolver\n *\n * Resolves the final set of enabled tools and required OAuth scopes\n * using a three-layer precedence model:\n *\n *   1. Baked-in defaults (from feature-config.ts)\n *   2. Settings from gemini-extension.json (future — placeholder layer)\n *   3. WORKSPACE_FEATURE_OVERRIDES env var (highest precedence)\n *\n * Override syntax examples:\n *   - Group-level:  \"gmail.write:off,slides.write:on\"\n *   - Tool-level:   \"calendar.deleteEvent:off,gmail.send:off\"\n *\n * Tool-level overrides are subtractive only — they can disable individual\n * tools within an enabled group but cannot enable tools in a disabled group.\n */\n\nimport { logToFile } from '../utils/logger';\nimport {\n  FEATURE_GROUPS,\n  featureGroupKey,\n  type FeatureGroup,\n} from './feature-config';\n\nexport interface ResolvedFeatures {\n  /** Set of tool names that should be registered */\n  enabledTools: Set<string>;\n  /** Deduplicated list of OAuth scopes required by enabled features */\n  requiredScopes: string[];\n}\n\ninterface Override {\n  key: string; // e.g. \"gmail.write\" or \"calendar.deleteEvent\"\n  enabled: boolean;\n}\n\n/**\n * Parses the WORKSPACE_FEATURE_OVERRIDES env var.\n *\n * Format: comma-separated \"key:on\" or \"key:off\" pairs.\n * Whitespace is trimmed. Empty entries are skipped.\n */\nexport function parseOverrides(raw: string): Override[] {\n  const overrides: Override[] = [];\n  for (const entry of raw.split(',')) {\n    const trimmed = entry.trim();\n    if (!trimmed) continue;\n\n    const colonIndex = trimmed.lastIndexOf(':');\n    if (colonIndex === -1) {\n      logToFile(\n        `[feature-resolver] Ignoring malformed override (missing ':'): \"${trimmed}\"`,\n      );\n      continue;\n    }\n\n    const key = trimmed.slice(0, colonIndex).trim();\n    const value = trimmed\n      .slice(colonIndex + 1)\n      .trim()\n      .toLowerCase();\n\n    if (value !== 'on' && value !== 'off') {\n      logToFile(\n        `[feature-resolver] Ignoring override with invalid value (expected on/off): \"${trimmed}\"`,\n      );\n      continue;\n    }\n\n    overrides.push({ key, enabled: value === 'on' });\n  }\n  return overrides;\n}\n\n/** Lookup from feature group key (\"docs.read\") to FeatureGroup. */\nconst GROUP_INDEX: ReadonlyMap<string, FeatureGroup> = new Map(\n  FEATURE_GROUPS.map((fg) => [featureGroupKey(fg), fg]),\n);\n\n/** Lookup from tool name to its feature group key. */\nconst TOOL_INDEX: ReadonlyMap<string, string> = new Map(\n  FEATURE_GROUPS.flatMap((fg) => {\n    const key = featureGroupKey(fg);\n    return fg.tools.map((tool) => [tool, key] as const);\n  }),\n);\n\n/**\n * Resolves which features are enabled and computes the required scopes.\n *\n * @param settingsOverrides - Future: overrides from gemini-extension.json settings UI\n * @param envOverrides - Raw value of WORKSPACE_FEATURE_OVERRIDES env var\n */\nexport function resolveFeatures(\n  settingsOverrides?: Record<string, boolean>,\n  envOverrides?: string,\n): ResolvedFeatures {\n  const groupIndex = GROUP_INDEX;\n  const toolIndex = TOOL_INDEX;\n\n  // Layer 1: Start with defaults\n  const groupEnabled = new Map<string, boolean>();\n  for (const fg of FEATURE_GROUPS) {\n    groupEnabled.set(featureGroupKey(fg), fg.defaultEnabled);\n  }\n\n  // Layer 2: Apply settings overrides (future — from gemini-extension.json)\n  if (settingsOverrides) {\n    for (const [key, enabled] of Object.entries(settingsOverrides)) {\n      if (groupIndex.has(key)) {\n        groupEnabled.set(key, enabled);\n      }\n    }\n  }\n\n  // Layer 3: Apply env var overrides (highest precedence)\n  const toolDisabled = new Set<string>();\n  if (envOverrides) {\n    const overrides = parseOverrides(envOverrides);\n    for (const { key, enabled } of overrides) {\n      if (groupIndex.has(key)) {\n        // Group-level override\n        groupEnabled.set(key, enabled);\n      } else if (toolIndex.has(key)) {\n        // Tool-level override (subtractive only)\n        if (!enabled) {\n          toolDisabled.add(key);\n        } else {\n          logToFile(\n            `[feature-resolver] Tool-level override \"${key}:on\" ignored — tool overrides are subtractive only`,\n          );\n        }\n      } else {\n        logToFile(\n          `[feature-resolver] Unknown override key: \"${key}\" — not a known feature group or tool`,\n        );\n      }\n    }\n  }\n\n  // Collect enabled tools and scopes.\n  //\n  // Scope dedup: when a service's `.write` group is enabled alongside its\n  // `.read` group, the write scope already grants read access at the API\n  // level, so we skip the read group's scopes. Avoids prompting the user\n  // for both `drive` and `drive.readonly` (and equivalents) on consent.\n  // Tools are unaffected — read tools still get registered.\n  const enabledTools = new Set<string>();\n  const scopeSet = new Set<string>();\n\n  for (const fg of FEATURE_GROUPS) {\n    const key = featureGroupKey(fg);\n    if (!groupEnabled.get(key)) continue;\n\n    const writeKey = `${fg.service}.write`;\n    const subsumedByWrite =\n      fg.group === 'read' &&\n      writeKey !== key &&\n      groupIndex.has(writeKey) &&\n      groupEnabled.get(writeKey) === true;\n\n    if (!subsumedByWrite) {\n      for (const scope of fg.scopes) {\n        scopeSet.add(scope);\n      }\n    }\n\n    // Add tools (minus individually disabled ones)\n    for (const tool of fg.tools) {\n      if (!toolDisabled.has(tool)) {\n        enabledTools.add(tool);\n      }\n    }\n  }\n\n  const requiredScopes = [...scopeSet];\n\n  logToFile(\n    `[feature-resolver] Resolved ${enabledTools.size} tools, ${requiredScopes.length} scopes`,\n  );\n\n  return { enabledTools, requiredScopes };\n}\n"
  },
  {
    "path": "workspace-server/src/features/index.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport {\n  FEATURE_GROUPS,\n  featureGroupKey,\n  getAllPossibleScopes,\n} from './feature-config';\nexport type { FeatureGroup } from './feature-config';\nexport { resolveFeatures, parseOverrides } from './feature-resolver';\nexport type { ResolvedFeatures } from './feature-resolver';\n"
  },
  {
    "path": "workspace-server/src/index.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { z } from 'zod';\nimport { AuthManager } from './auth/AuthManager';\nimport { DocsService } from './services/DocsService';\nimport { DriveService } from './services/DriveService';\nimport { CalendarService } from './services/CalendarService';\nimport { ChatService } from './services/ChatService';\nimport { GmailService } from './services/GmailService';\nimport { TimeService } from './services/TimeService';\nimport { PeopleService } from './services/PeopleService';\nimport { SlidesService } from './services/SlidesService';\nimport { SheetsService } from './services/SheetsService';\nimport { GMAIL_SEARCH_MAX_RESULTS } from './utils/constants';\n\nimport { setLoggingEnabled, logToFile } from './utils/logger';\nimport { applyToolNameNormalization } from './utils/tool-normalization';\nimport { SCOPES } from './auth/scopes';\nimport { resolveFeatures } from './features/index';\n\n// Shared schemas for calendar event tools\nconst eventDateInputSchema = (fieldName: string) =>\n  z\n    .object({\n      dateTime: z\n        .string()\n        .optional()\n        .describe(\n          'Time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T10:30:00Z or 2024-01-15T10:30:00-05:00).',\n        ),\n      date: z\n        .string()\n        .optional()\n        .describe('Date in YYYY-MM-DD format. Use for all-day events.'),\n    })\n    .refine(({ dateTime, date }) => Number(!!dateTime) + Number(!!date) === 1, {\n      message: `${fieldName} must have exactly one of \"dateTime\" (for timed events) or \"date\" (for all-day events)`,\n    });\n\nconst eventMeetAndAttachmentsSchema = {\n  addGoogleMeet: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Whether to create a Google Meet link for the event. The Meet URL will be available in the response's hangoutLink field.\",\n    ),\n  attachments: z\n    .array(\n      z.object({\n        fileUrl: z\n          .string()\n          .url()\n          .describe(\n            'Google Drive file URL (e.g., https://drive.google.com/file/d/...)',\n          ),\n        title: z\n          .string()\n          .optional()\n          .describe('Display title for the attachment.'),\n        mimeType: z\n          .string()\n          .optional()\n          .describe('MIME type of the attachment.'),\n      }),\n    )\n    .optional()\n    .describe(\n      'Google Drive file attachments. IMPORTANT: Providing attachments fully REPLACES any existing attachments on the event (not appended). On updates, pass an empty array to clear all attachments.',\n    ),\n};\n\n// Shared schemas for Gmail tools\nconst emailComposeSchema = {\n  to: z\n    .union([z.string(), z.array(z.string())])\n    .describe('Recipient email address(es).'),\n  subject: z.string().describe('Email subject.'),\n  body: z.string().describe('Email body content.'),\n  cc: z\n    .union([z.string(), z.array(z.string())])\n    .optional()\n    .describe('CC recipient email address(es).'),\n  bcc: z\n    .union([z.string(), z.array(z.string())])\n    .optional()\n    .describe('BCC recipient email address(es).'),\n  isHtml: z\n    .boolean()\n    .optional()\n    .describe('Whether the body is HTML (default: false).'),\n};\n\n// Dynamically import version from package.json\nimport { version } from '../package.json';\n\nasync function main() {\n  // Handle 'login' subcommand for headless OAuth flow\n  if (process.argv.includes('login')) {\n    await import('./cli/headless-login');\n    return;\n  }\n\n  // 1. Initialize services\n  if (process.argv.includes('--debug')) {\n    setLoggingEnabled(true);\n  }\n\n  const readOnlyToolProps = {\n    annotations: {\n      readOnlyHint: true,\n    },\n  };\n\n  // Resolve enabled features from defaults + env overrides\n  const { enabledTools } = resolveFeatures(\n    undefined,\n    process.env['WORKSPACE_FEATURE_OVERRIDES'],\n  );\n\n  logToFile(\n    `[features] ${enabledTools.size} tools enabled. Disabled: ${\n      process.env['WORKSPACE_FEATURE_OVERRIDES'] || '(none)'\n    }`,\n  );\n\n  const authManager = new AuthManager(SCOPES);\n\n  // 2. Create the server instance\n  const server = new McpServer({\n    name: 'google-workspace-server',\n    version,\n  });\n\n  authManager.setOnStatusUpdate((message) => {\n    server\n      .sendLoggingMessage({\n        level: 'info',\n        data: message,\n      })\n      .catch((err) => {\n        console.error('Failed to send logging message:', err);\n      });\n  });\n\n  const driveService = new DriveService(authManager);\n  const docsService = new DocsService(authManager);\n  const peopleService = new PeopleService(authManager);\n  const calendarService = new CalendarService(authManager);\n  const chatService = new ChatService(authManager);\n  const gmailService = new GmailService(authManager);\n  const timeService = new TimeService();\n  const slidesService = new SlidesService(authManager);\n  const sheetsService = new SheetsService(authManager);\n\n  // 3. Register tools directly on the server\n  // Handle tool name normalization (dots to underscores) by default, or use dots if --use-dot-names is passed.\n  const useDotNames = process.argv.includes('--use-dot-names');\n  const separator = useDotNames ? '.' : '_';\n  applyToolNameNormalization(server, useDotNames);\n\n  // Wrap registerTool to skip tools disabled by feature config.\n  // Auth tools are always registered (not gated by features).\n  const originalRegisterTool = server.registerTool.bind(server);\n  const registerTool: typeof server.registerTool = ((\n    name: string,\n    config: unknown,\n    handler: unknown,\n  ) => {\n    if (!enabledTools.has(name) && !name.startsWith('auth.')) {\n      logToFile(`[features] Skipping disabled tool: ${name}`);\n      return server;\n    }\n    return originalRegisterTool(name, config as never, handler as never);\n  }) as typeof server.registerTool;\n\n  registerTool(\n    'auth.clear',\n    {\n      description:\n        'Clears the authentication credentials, forcing a re-login on the next request.',\n      inputSchema: {},\n    },\n    async () => {\n      await authManager.clearAuth();\n      return {\n        content: [\n          {\n            type: 'text',\n            text: 'Authentication credentials cleared. You will be prompted to log in again on the next request.',\n          },\n        ],\n      };\n    },\n  );\n\n  registerTool(\n    'auth.refreshToken',\n    {\n      description: 'Manually triggers the token refresh process.',\n      inputSchema: {},\n    },\n    async () => {\n      await authManager.refreshToken();\n      return {\n        content: [\n          {\n            type: 'text',\n            text: 'Token refresh process triggered successfully.',\n          },\n        ],\n      };\n    },\n  );\n\n  registerTool(\n    'docs.getSuggestions',\n    {\n      description: 'Retrieves suggested edits from a Google Doc.',\n      inputSchema: {\n        documentId: z\n          .string()\n          .describe('The ID of the document to retrieve suggestions from.'),\n      },\n    },\n    docsService.getSuggestions,\n  );\n\n  registerTool(\n    'drive.getComments',\n    {\n      description:\n        'Retrieves comments from a Google Drive file (Docs, Sheets, Slides, etc.).',\n      inputSchema: {\n        fileId: z\n          .string()\n          .describe('The ID of the file to retrieve comments from.'),\n      },\n    },\n    driveService.getComments,\n  );\n\n  registerTool(\n    'docs.create',\n    {\n      description:\n        'Creates a new Google Doc. Can be blank or with initial text content.',\n      inputSchema: {\n        title: z.string().describe('The title for the new Google Doc.'),\n        content: z\n          .string()\n          .optional()\n          .describe('The text content to create the document with.'),\n      },\n    },\n    docsService.create,\n  );\n\n  registerTool(\n    'docs.writeText',\n    {\n      description: 'Writes text to a Google Doc at a specified position.',\n      inputSchema: {\n        documentId: z.string().describe('The ID of the document to modify.'),\n        text: z.string().describe('The text to write to the document.'),\n        position: z\n          .string()\n          .optional()\n          .describe(\n            'Where to insert the text. Use \"beginning\" for the start, \"end\" for the end (default), or a numeric index for a specific position.',\n          ),\n        tabId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the tab to modify. If not provided, modifies the first tab.',\n          ),\n      },\n    },\n    docsService.writeText,\n  );\n\n  registerTool(\n    'drive.findFolder',\n    {\n      description: 'Finds a folder by name in Google Drive.',\n      inputSchema: {\n        folderName: z.string().describe('The name of the folder to find.'),\n      },\n      ...readOnlyToolProps,\n    },\n    driveService.findFolder,\n  );\n\n  registerTool(\n    'drive.createFolder',\n    {\n      description: 'Creates a new folder in Google Drive.',\n      inputSchema: {\n        name: z.string().trim().min(1).describe('The name of the new folder.'),\n        parentId: z\n          .string()\n          .trim()\n          .min(1)\n          .optional()\n          .describe(\n            'The ID of the parent folder. If not provided, creates in the root directory.',\n          ),\n      },\n    },\n    driveService.createFolder,\n  );\n\n  registerTool(\n    'docs.getText',\n    {\n      description: 'Retrieves the text content of a Google Doc.',\n      inputSchema: {\n        documentId: z.string().describe('The ID of the document to read.'),\n        tabId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the tab to read. If not provided, returns all tabs.',\n          ),\n      },\n      ...readOnlyToolProps,\n    },\n    docsService.getText,\n  );\n\n  registerTool(\n    'docs.replaceText',\n    {\n      description:\n        'Replaces all occurrences of a given text with new text in a Google Doc.',\n      inputSchema: {\n        documentId: z.string().describe('The ID of the document to modify.'),\n        findText: z.string().describe('The text to find in the document.'),\n        replaceText: z\n          .string()\n          .describe('The text to replace the found text with.'),\n        tabId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the tab to modify. If not provided, replaces in all tabs (legacy behavior).',\n          ),\n      },\n    },\n    docsService.replaceText,\n  );\n\n  registerTool(\n    'docs.formatText',\n    {\n      description:\n        'Applies formatting (bold, italic, headings, etc.) to text ranges in a Google Doc. Use after inserting text to apply rich formatting.',\n      inputSchema: {\n        documentId: z.string().describe('The ID of the document to format.'),\n        formats: z\n          .array(\n            z.object({\n              startIndex: z\n                .number()\n                .describe('The start index of the text range (1-based).'),\n              endIndex: z\n                .number()\n                .describe(\n                  'The end index of the text range (exclusive, 1-based).',\n                ),\n              style: z\n                .string()\n                .describe(\n                  'The formatting style to apply. Supported: bold, italic, underline, strikethrough, code, link, heading1, heading2, heading3, heading4, heading5, heading6, normalText.',\n                ),\n              url: z\n                .string()\n                .optional()\n                .describe(\n                  'The URL for link formatting. Required when style is \"link\".',\n                ),\n            }),\n          )\n          .describe('The formatting instructions to apply.'),\n        tabId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the tab to format. If not provided, formats the first tab.',\n          ),\n      },\n    },\n    docsService.formatText,\n  );\n\n  // Slides tools\n  registerTool(\n    'slides.getText',\n    {\n      description:\n        'Retrieves the text content of a Google Slides presentation.',\n      inputSchema: {\n        presentationId: z\n          .string()\n          .describe('The ID or URL of the presentation to read.'),\n      },\n      ...readOnlyToolProps,\n    },\n    slidesService.getText,\n  );\n\n  registerTool(\n    'slides.getMetadata',\n    {\n      description: 'Gets metadata about a Google Slides presentation.',\n      inputSchema: {\n        presentationId: z\n          .string()\n          .describe('The ID or URL of the presentation.'),\n      },\n      ...readOnlyToolProps,\n    },\n    slidesService.getMetadata,\n  );\n\n  registerTool(\n    'slides.getImages',\n    {\n      description:\n        'Downloads all images embedded in a Google Slides presentation to a local directory.',\n      inputSchema: {\n        presentationId: z\n          .string()\n          .describe(\n            'The ID or URL of the presentation to extract images from.',\n          ),\n        localPath: z\n          .string()\n          .describe(\n            'The absolute local directory path to download the images to (e.g., \"/Users/name/downloads/images\").',\n          ),\n      },\n    },\n    slidesService.getImages,\n  );\n\n  registerTool(\n    'slides.getSlideThumbnail',\n    {\n      description:\n        'Downloads a thumbnail image for a specific slide in a Google Slides presentation to a local path.',\n      inputSchema: {\n        presentationId: z\n          .string()\n          .describe('The ID or URL of the presentation.'),\n        slideObjectId: z\n          .string()\n          .describe(\n            'The object ID of the slide (can be found via slides.getMetadata or slides.getText).',\n          ),\n        localPath: z\n          .string()\n          .describe(\n            'The absolute local file path to download the thumbnail to (e.g., \"/Users/name/downloads/slide1.png\").',\n          ),\n      },\n    },\n    slidesService.getSlideThumbnail,\n  );\n\n  // Sheets tools\n  registerTool(\n    'sheets.getText',\n    {\n      description: 'Retrieves the content of a Google Sheets spreadsheet.',\n      inputSchema: {\n        spreadsheetId: z\n          .string()\n          .describe('The ID or URL of the spreadsheet to read.'),\n        format: z\n          .enum(['text', 'csv', 'json'])\n          .optional()\n          .describe('Output format (default: text).'),\n      },\n      ...readOnlyToolProps,\n    },\n    sheetsService.getText,\n  );\n\n  registerTool(\n    'sheets.getRange',\n    {\n      description:\n        'Gets values from a specific range in a Google Sheets spreadsheet.',\n      inputSchema: {\n        spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'),\n        range: z\n          .string()\n          .describe('The A1 notation range to get (e.g., \"Sheet1!A1:B10\").'),\n      },\n      ...readOnlyToolProps,\n    },\n    sheetsService.getRange,\n  );\n\n  registerTool(\n    'sheets.getMetadata',\n    {\n      description: 'Gets metadata about a Google Sheets spreadsheet.',\n      inputSchema: {\n        spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'),\n      },\n      ...readOnlyToolProps,\n    },\n    sheetsService.getMetadata,\n  );\n\n  registerTool(\n    'drive.search',\n    {\n      description:\n        'Searches for files and folders in Google Drive. The query can be a simple search term, a Google Drive URL, or a full query string. For more information on query strings see: https://developers.google.com/drive/api/guides/search-files',\n      inputSchema: {\n        query: z\n          .string()\n          .optional()\n          .describe(\n            'A simple search term (e.g., \"Budget Q3\"), a Google Drive URL, or a full query string (e.g., \"name contains \\'Budget\\' and owners in \\'user@example.com\\'\").',\n          ),\n        pageSize: z\n          .number()\n          .optional()\n          .describe('The maximum number of results to return.'),\n        pageToken: z\n          .string()\n          .optional()\n          .describe('The token for the next page of results.'),\n        corpus: z\n          .string()\n          .optional()\n          .describe('The corpus of files to search (e.g., \"user\", \"domain\").'),\n        unreadOnly: z\n          .boolean()\n          .optional()\n          .describe('Whether to filter for unread files only.'),\n        sharedWithMe: z\n          .boolean()\n          .optional()\n          .describe('Whether to search for files shared with the user.'),\n      },\n      ...readOnlyToolProps,\n    },\n    driveService.search,\n  );\n\n  registerTool(\n    'drive.downloadFile',\n    {\n      description:\n        'Downloads the content of a file from Google Drive to a local path. Note: Google Docs, Sheets, and Slides require specialized handling.',\n      inputSchema: {\n        fileId: z.string().describe('The ID of the file to download.'),\n        localPath: z\n          .string()\n          .describe(\n            'The local file path where the content should be saved (e.g., \"downloads/report.pdf\").',\n          ),\n      },\n    },\n    driveService.downloadFile,\n  );\n\n  registerTool(\n    'drive.moveFile',\n    {\n      description:\n        'Moves a file or folder to a different folder in Google Drive.',\n      inputSchema: {\n        fileId: z.string().describe('The ID or URL of the file to move.'),\n        folderId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the destination folder. Either folderId or folderName must be provided.',\n          ),\n        folderName: z\n          .string()\n          .optional()\n          .describe(\n            'The name of the destination folder. Either folderId or folderName must be provided.',\n          ),\n      },\n    },\n    driveService.moveFile,\n  );\n\n  registerTool(\n    'drive.trashFile',\n    {\n      description:\n        'Moves a file or folder to the trash in Google Drive. This is a safe, reversible operation.',\n      inputSchema: {\n        fileId: z.string().describe('The ID or URL of the file to trash.'),\n      },\n    },\n    driveService.trashFile,\n  );\n\n  registerTool(\n    'drive.renameFile',\n    {\n      description: 'Renames a file or folder in Google Drive.',\n      inputSchema: {\n        fileId: z.string().describe('The ID or URL of the file to rename.'),\n        newName: z\n          .string()\n          .trim()\n          .min(1)\n          .describe('The new name for the file.'),\n      },\n    },\n    driveService.renameFile,\n  );\n\n  registerTool(\n    'calendar.list',\n    {\n      description: \"Lists all of the user's calendars.\",\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    calendarService.listCalendars,\n  );\n\n  registerTool(\n    'calendar.createEvent',\n    {\n      description:\n        \"Creates a new event in a calendar. Supports regular events, focus time, out-of-office, and working location event types. Use 'date' for all-day events or 'dateTime' for timed events. Supports optional Google Meet link generation and Google Drive file attachments. When addGoogleMeet is true, the Meet URL will be in the response's hangoutLink field. Attachments fully replace any existing attachments.\",\n      inputSchema: {\n        calendarId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the calendar to create the event in. Defaults to the primary calendar.',\n          ),\n        summary: z\n          .string()\n          .optional()\n          .describe(\n            'The summary or title of the event. Defaults based on eventType: \"Focus Time\", \"Out of Office\", \"Working Location\".',\n          ),\n        description: z\n          .string()\n          .optional()\n          .describe('The description of the event.'),\n        start: eventDateInputSchema('start'),\n        end: eventDateInputSchema('end'),\n        attendees: z\n          .array(z.string())\n          .optional()\n          .describe('The email addresses of the attendees.'),\n        sendUpdates: z\n          .enum(['all', 'externalOnly', 'none'])\n          .optional()\n          .describe(\n            'Whether to send notifications to attendees. Defaults to \"all\" if attendees are provided, otherwise \"none\".',\n          ),\n        ...eventMeetAndAttachmentsSchema,\n        eventType: z\n          .enum(['default', 'focusTime', 'outOfOffice', 'workingLocation'])\n          .optional()\n          .describe(\n            'The type of event to create. Defaults to \"default\" (regular event).',\n          ),\n        focusTimeProperties: z\n          .object({\n            chatStatus: z\n              .enum(['available', 'doNotDisturb'])\n              .optional()\n              .describe(\n                'Chat status during focus time. Defaults to \"doNotDisturb\".',\n              ),\n            autoDeclineMode: z\n              .enum([\n                'declineNone',\n                'declineAllConflictingInvitations',\n                'declineOnlyNewConflictingInvitations',\n              ])\n              .optional()\n              .describe(\n                'How to handle conflicting meeting invitations. Defaults to \"declineOnlyNewConflictingInvitations\".',\n              ),\n            declineMessage: z\n              .string()\n              .optional()\n              .describe('Message to send when auto-declining meetings.'),\n          })\n          .optional()\n          .describe(\n            'Focus time properties. Only used when eventType is \"focusTime\".',\n          ),\n        outOfOfficeProperties: z\n          .object({\n            autoDeclineMode: z\n              .enum([\n                'declineNone',\n                'declineAllConflictingInvitations',\n                'declineOnlyNewConflictingInvitations',\n              ])\n              .optional()\n              .describe(\n                'How to handle conflicting meeting invitations. Defaults to \"declineOnlyNewConflictingInvitations\".',\n              ),\n            declineMessage: z\n              .string()\n              .optional()\n              .describe('Message to send when auto-declining meetings.'),\n          })\n          .optional()\n          .describe(\n            'Out-of-office properties. Only used when eventType is \"outOfOffice\".',\n          ),\n        workingLocationProperties: z\n          .object({\n            type: z\n              .enum(['homeOffice', 'officeLocation', 'customLocation'])\n              .describe('The type of working location.'),\n            officeLocation: z\n              .object({\n                buildingId: z\n                  .string()\n                  .optional()\n                  .describe('The building ID from the directory.'),\n                label: z\n                  .string()\n                  .optional()\n                  .describe('Label for the office location.'),\n              })\n              .optional()\n              .describe(\n                'Office location details. Required when type is \"officeLocation\".',\n              ),\n            customLocation: z\n              .object({\n                label: z.string().describe('Label for the custom location.'),\n              })\n              .optional()\n              .describe(\n                'Custom location details. Required when type is \"customLocation\".',\n              ),\n          })\n          .optional()\n          .describe(\n            'Working location properties. Only used when eventType is \"workingLocation\".',\n          ),\n      },\n    },\n    calendarService.createEvent,\n  );\n\n  registerTool(\n    'calendar.listEvents',\n    {\n      description: 'Lists events from a calendar. Defaults to upcoming events.',\n      inputSchema: {\n        calendarId: z\n          .string()\n          .describe('The ID of the calendar to list events from.'),\n        timeMin: z\n          .string()\n          .optional()\n          .describe(\n            'The start time for the event search. Defaults to the current time.',\n          ),\n        timeMax: z\n          .string()\n          .optional()\n          .describe('The end time for the event search.'),\n        attendeeResponseStatus: z\n          .array(z.string())\n          .optional()\n          .describe('The response status of the attendee.'),\n        eventTypes: z\n          .array(\n            z.enum([\n              'default',\n              'focusTime',\n              'outOfOffice',\n              'workingLocation',\n              'birthday',\n              'fromGmail',\n            ]),\n          )\n          .optional()\n          .describe(\n            'Filter by event types. Possible values: default, focusTime, outOfOffice, workingLocation, birthday, fromGmail.',\n          ),\n      },\n      ...readOnlyToolProps,\n    },\n    calendarService.listEvents,\n  );\n\n  registerTool(\n    'calendar.getEvent',\n    {\n      description: 'Gets the details of a specific calendar event.',\n      inputSchema: {\n        eventId: z.string().describe('The ID of the event to retrieve.'),\n        calendarId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the calendar the event belongs to. Defaults to the primary calendar.',\n          ),\n      },\n      ...readOnlyToolProps,\n    },\n    calendarService.getEvent,\n  );\n\n  registerTool(\n    'calendar.findFreeTime',\n    {\n      description: 'Finds a free time slot for multiple people to meet.',\n      inputSchema: {\n        attendees: z\n          .array(z.string())\n          .describe('The email addresses of the attendees.'),\n        timeMin: z\n          .string()\n          .describe(\n            'The start time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T09:00:00Z or 2024-01-15T09:00:00-05:00).',\n          ),\n        timeMax: z\n          .string()\n          .describe(\n            'The end time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T18:00:00Z or 2024-01-15T18:00:00-05:00).',\n          ),\n        duration: z\n          .number()\n          .describe('The duration of the meeting in minutes.'),\n      },\n      ...readOnlyToolProps,\n    },\n    calendarService.findFreeTime,\n  );\n\n  registerTool(\n    'calendar.updateEvent',\n    {\n      description:\n        \"Updates an existing event in a calendar while preserving unspecified fields. Supports both timed events (`dateTime`) and all-day events (`date`), along with Google Meet links and Google Drive file attachments. When addGoogleMeet is true, the Meet URL will be in the response's hangoutLink field. Attachments fully replace any existing attachments (not appended), and an empty attachments array clears them.\",\n      inputSchema: {\n        eventId: z.string().describe('The ID of the event to update.'),\n        calendarId: z\n          .string()\n          .optional()\n          .describe('The ID of the calendar to update the event in.'),\n        summary: z\n          .string()\n          .optional()\n          .describe('The new summary or title of the event.'),\n        description: z\n          .string()\n          .optional()\n          .describe('The new description of the event.'),\n        start: eventDateInputSchema('start').optional(),\n        end: eventDateInputSchema('end').optional(),\n        attendees: z\n          .array(z.string())\n          .optional()\n          .describe('The new list of attendees for the event.'),\n        ...eventMeetAndAttachmentsSchema,\n      },\n    },\n    calendarService.updateEvent,\n  );\n\n  registerTool(\n    'calendar.respondToEvent',\n    {\n      description:\n        'Responds to a meeting invitation (accept, decline, or tentative).',\n      inputSchema: {\n        eventId: z.string().describe('The ID of the event to respond to.'),\n        calendarId: z\n          .string()\n          .optional()\n          .describe('The ID of the calendar containing the event.'),\n        responseStatus: z\n          .enum(['accepted', 'declined', 'tentative'])\n          .describe('Your response to the invitation.'),\n        sendNotification: z\n          .boolean()\n          .optional()\n          .describe(\n            'Whether to send a notification to the organizer (default: true).',\n          ),\n        responseMessage: z\n          .string()\n          .optional()\n          .describe('Optional message to include with your response.'),\n      },\n    },\n    calendarService.respondToEvent,\n  );\n\n  registerTool(\n    'calendar.deleteEvent',\n    {\n      description: 'Deletes an event from a calendar.',\n      inputSchema: {\n        eventId: z.string().describe('The ID of the event to delete.'),\n        calendarId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the calendar to delete the event from. Defaults to the primary calendar.',\n          ),\n      },\n    },\n    calendarService.deleteEvent,\n  );\n\n  registerTool(\n    'chat.listSpaces',\n    {\n      description: 'Lists the spaces the user is a member of.',\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    chatService.listSpaces,\n  );\n\n  registerTool(\n    'chat.findSpaceByName',\n    {\n      description: 'Finds a Google Chat space by its display name.',\n      inputSchema: {\n        displayName: z\n          .string()\n          .describe('The display name of the space to find.'),\n      },\n      ...readOnlyToolProps,\n    },\n    chatService.findSpaceByName,\n  );\n\n  registerTool(\n    'chat.sendMessage',\n    {\n      description: 'Sends a message to a Google Chat space.',\n      inputSchema: {\n        spaceName: z\n          .string()\n          .describe(\n            'The name of the space to send the message to (e.g., spaces/AAAAN2J52O8).',\n          ),\n        message: z.string().describe('The message to send.'),\n        threadName: z\n          .string()\n          .optional()\n          .describe(\n            'The resource name of the thread to reply to. Example: \"spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg\"',\n          ),\n      },\n    },\n    chatService.sendMessage,\n  );\n\n  registerTool(\n    'chat.getMessages',\n    {\n      description: 'Gets messages from a Google Chat space.',\n      inputSchema: {\n        spaceName: z\n          .string()\n          .describe(\n            'The name of the space to get messages from (e.g., spaces/AAAAN2J52O8).',\n          ),\n        threadName: z\n          .string()\n          .optional()\n          .describe(\n            'The resource name of the thread to filter messages by. Example: \"spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg\"',\n          ),\n        unreadOnly: z\n          .boolean()\n          .optional()\n          .describe('Whether to return only unread messages.'),\n        pageSize: z\n          .number()\n          .optional()\n          .describe('The maximum number of messages to return.'),\n        pageToken: z\n          .string()\n          .optional()\n          .describe('The token for the next page of results.'),\n        orderBy: z\n          .string()\n          .optional()\n          .describe('The order to list messages in (e.g., \"createTime desc\").'),\n      },\n      ...readOnlyToolProps,\n    },\n    chatService.getMessages,\n  );\n\n  registerTool(\n    'chat.sendDm',\n    {\n      description: 'Sends a direct message to a user.',\n      inputSchema: {\n        email: z\n          .string()\n          .email()\n          .describe('The email address of the user to send the message to.'),\n        message: z.string().describe('The message to send.'),\n        threadName: z\n          .string()\n          .optional()\n          .describe(\n            'The resource name of the thread to reply to. Example: \"spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg\"',\n          ),\n      },\n    },\n    chatService.sendDm,\n  );\n\n  registerTool(\n    'chat.findDmByEmail',\n    {\n      description: \"Finds a Google Chat DM space by a user's email address.\",\n      inputSchema: {\n        email: z\n          .string()\n          .email()\n          .describe('The email address of the user to find the DM space with.'),\n      },\n      ...readOnlyToolProps,\n    },\n    chatService.findDmByEmail,\n  );\n\n  registerTool(\n    'chat.listThreads',\n    {\n      description:\n        'Lists threads from a Google Chat space in reverse chronological order.',\n      inputSchema: {\n        spaceName: z\n          .string()\n          .describe(\n            'The name of the space to get threads from (e.g., spaces/AAAAN2J52O8).',\n          ),\n        pageSize: z\n          .number()\n          .optional()\n          .describe('The maximum number of threads to return.'),\n        pageToken: z\n          .string()\n          .optional()\n          .describe('The token for the next page of results.'),\n      },\n      ...readOnlyToolProps,\n    },\n    chatService.listThreads,\n  );\n\n  registerTool(\n    'chat.setUpSpace',\n    {\n      description:\n        'Sets up a new Google Chat space with a display name and a list of members.',\n      inputSchema: {\n        displayName: z.string().describe('The display name of the space.'),\n        userNames: z\n          .array(z.string())\n          .describe(\n            'The user names of the members to add to the space (e.g. users/12345678)',\n          ),\n      },\n    },\n    chatService.setUpSpace,\n  );\n\n  // Gmail tools\n  registerTool(\n    'gmail.search',\n    {\n      description: 'Search for emails in Gmail using query parameters.',\n      inputSchema: {\n        query: z\n          .string()\n          .optional()\n          .describe(\n            'Search query (same syntax as Gmail search box, e.g., \"from:someone@example.com is:unread\").',\n          ),\n        maxResults: z\n          .number()\n          .optional()\n          .describe(\n            `Maximum number of results to return (default: ${GMAIL_SEARCH_MAX_RESULTS}).`,\n          ),\n        pageToken: z\n          .string()\n          .optional()\n          .describe('Token for the next page of results.'),\n        labelIds: z\n          .array(z.string())\n          .optional()\n          .describe('Filter by label IDs (e.g., [\"INBOX\", \"UNREAD\"]).'),\n        includeSpamTrash: z\n          .boolean()\n          .optional()\n          .describe('Include messages from SPAM and TRASH (default: false).'),\n      },\n      ...readOnlyToolProps,\n    },\n    gmailService.search,\n  );\n\n  registerTool(\n    'gmail.get',\n    {\n      description: 'Get the full content of a specific email message.',\n      inputSchema: {\n        messageId: z.string().describe('The ID of the message to retrieve.'),\n        format: z\n          .enum(['minimal', 'full', 'raw', 'metadata'])\n          .optional()\n          .describe('Format of the message (default: full).'),\n      },\n      ...readOnlyToolProps,\n    },\n    gmailService.get,\n  );\n\n  registerTool(\n    'gmail.downloadAttachment',\n    {\n      description:\n        'Downloads an attachment from a Gmail message to a local file.',\n      inputSchema: {\n        messageId: z\n          .string()\n          .describe('The ID of the message containing the attachment.'),\n        attachmentId: z\n          .string()\n          .describe('The ID of the attachment to download.'),\n        localPath: z\n          .string()\n          .describe(\n            'The absolute local path where the attachment should be saved (e.g., \"/Users/name/downloads/report.pdf\").',\n          ),\n      },\n    },\n    gmailService.downloadAttachment,\n  );\n\n  registerTool(\n    'gmail.modify',\n    {\n      description: `Modify a Gmail message. Supported modifications include:\n    - Add labels to a message.\n    - Remove labels from a message.\nThere are a list of system labels that can be modified on a message:\n    - INBOX: removing INBOX label removes the message from inbox and archives the message.\n    - SPAM: adding SPAM label marks a message as spam.\n    - TRASH: adding TRASH label moves a message to trash.\n    - UNREAD: removing UNREAD label marks a message as read.\n    - STARRED: adding STARRED label marks a message as starred.\n    - IMPORTANT: adding IMPORTANT label marks a message as important.`,\n      inputSchema: {\n        messageId: z\n          .string()\n          .describe(\n            'The ID of the message to add labels to and/or remove labels from.',\n          ),\n        addLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to add to the message. Limit to 100 labels.',\n          ),\n        removeLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to remove from the message. Limit to 100 labels.',\n          ),\n      },\n    },\n    gmailService.modify,\n  );\n\n  registerTool(\n    'gmail.batchModify',\n    {\n      description: `Bulk modify up to 1,000 Gmail messages at once. Applies the same label changes to all specified messages in a single API call. This is much more efficient than modifying messages individually.\n    - Add labels to messages.\n    - Remove labels from messages.\nSystem labels that can be modified:\n    - INBOX: removing INBOX label archives messages.\n    - SPAM: adding SPAM label marks messages as spam.\n    - TRASH: adding TRASH label moves messages to trash.\n    - UNREAD: removing UNREAD label marks messages as read.\n    - STARRED: adding STARRED label marks messages as starred.\n    - IMPORTANT: adding IMPORTANT label marks messages as important.`,\n      inputSchema: {\n        messageIds: z\n          .array(z.string())\n          .min(1, { message: 'At least one message ID must be provided.' })\n          .max(1000)\n          .describe(\n            'The IDs of the messages to modify. Maximum 1,000 per call.',\n          ),\n        addLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to add to the messages. Limit to 100 labels.',\n          ),\n        removeLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to remove from the messages. Limit to 100 labels.',\n          ),\n      },\n    },\n    gmailService.batchModify,\n  );\n\n  registerTool(\n    'gmail.modifyThread',\n    {\n      description: `Modify labels on all messages in a Gmail thread. This applies label changes to every message in the thread at once, which is useful for operations like marking an entire conversation as read.\nSystem labels that can be modified:\n    - INBOX: removing INBOX label archives the thread.\n    - SPAM: adding SPAM label marks the thread as spam.\n    - TRASH: adding TRASH label moves the thread to trash.\n    - UNREAD: removing UNREAD label marks all messages in the thread as read.\n    - STARRED: adding STARRED label marks the thread as starred.\n    - IMPORTANT: adding IMPORTANT label marks the thread as important.`,\n      inputSchema: {\n        threadId: z.string().describe('The ID of the thread to modify.'),\n        addLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to add to the thread. Limit to 100 labels.',\n          ),\n        removeLabelIds: z\n          .array(z.string())\n          .max(100)\n          .optional()\n          .describe(\n            'A list of label IDs to remove from the thread. Limit to 100 labels.',\n          ),\n      },\n    },\n    gmailService.modifyThread,\n  );\n\n  registerTool(\n    'gmail.send',\n    {\n      description: 'Send an email message.',\n      inputSchema: emailComposeSchema,\n    },\n    gmailService.send,\n  );\n\n  registerTool(\n    'gmail.createDraft',\n    {\n      description: 'Create a draft email message.',\n      inputSchema: {\n        ...emailComposeSchema,\n        threadId: z\n          .string()\n          .optional()\n          .describe(\n            'The thread ID to create the draft as a reply to. When provided, the draft will be linked to the existing thread with appropriate reply headers.',\n          ),\n      },\n    },\n    gmailService.createDraft,\n  );\n\n  registerTool(\n    'gmail.sendDraft',\n    {\n      description: 'Send a previously created draft email.',\n      inputSchema: {\n        draftId: z.string().describe('The ID of the draft to send.'),\n      },\n    },\n    gmailService.sendDraft,\n  );\n\n  registerTool(\n    'gmail.listLabels',\n    {\n      description: \"List all Gmail labels in the user's mailbox.\",\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    gmailService.listLabels,\n  );\n\n  registerTool(\n    'gmail.createLabel',\n    {\n      description:\n        'Create a new Gmail label. Labels help organize emails into categories.',\n      inputSchema: {\n        name: z.string().min(1).describe('The display name of the label.'),\n        labelListVisibility: z\n          .enum(['labelShow', 'labelHide', 'labelShowIfUnread'])\n          .optional()\n          .describe(\n            'Visibility of the label in the label list. Defaults to \"labelShow\".',\n          ),\n        messageListVisibility: z\n          .enum(['show', 'hide'])\n          .optional()\n          .describe(\n            'Visibility of messages with this label in the message list. Defaults to \"show\".',\n          ),\n      },\n    },\n    gmailService.createLabel,\n  );\n\n  // Time tools\n  registerTool(\n    'time.getCurrentDate',\n    {\n      description:\n        'Gets the current date. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.',\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    timeService.getCurrentDate,\n  );\n\n  registerTool(\n    'time.getCurrentTime',\n    {\n      description:\n        'Gets the current time. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.',\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    timeService.getCurrentTime,\n  );\n\n  registerTool(\n    'time.getTimeZone',\n    {\n      description:\n        'Gets the local timezone. Note: timezone is also included in getCurrentDate and getCurrentTime responses.',\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    timeService.getTimeZone,\n  );\n\n  // People tools\n  registerTool(\n    'people.getUserProfile',\n    {\n      description: \"Gets a user's profile information.\",\n      inputSchema: {\n        userId: z\n          .string()\n          .optional()\n          .describe('The ID of the user to get profile information for.'),\n        email: z\n          .string()\n          .optional()\n          .describe(\n            'The email address of the user to get profile information for.',\n          ),\n        name: z\n          .string()\n          .optional()\n          .describe('The name of the user to get profile information for.'),\n      },\n      ...readOnlyToolProps,\n    },\n    peopleService.getUserProfile,\n  );\n\n  registerTool(\n    'people.getMe',\n    {\n      description: 'Gets the profile information of the authenticated user.',\n      inputSchema: {},\n      ...readOnlyToolProps,\n    },\n    peopleService.getMe,\n  );\n\n  registerTool(\n    'people.getUserRelations',\n    {\n      description:\n        \"Gets a user's relations (e.g., manager, spouse, assistant, etc.). Common relation types include: manager, assistant, spouse, partner, relative, mother, father, parent, sibling, child, friend, domesticPartner, referredBy. Defaults to the authenticated user if no userId is provided.\",\n      inputSchema: {\n        userId: z\n          .string()\n          .optional()\n          .describe(\n            'The ID of the user to get relations for (e.g., \"110001608645105799644\" or \"people/110001608645105799644\"). Defaults to the authenticated user if not provided.',\n          ),\n        relationType: z\n          .string()\n          .optional()\n          .describe(\n            'The type of relation to filter by (e.g., \"manager\", \"spouse\", \"assistant\"). If not provided, returns all relations.',\n          ),\n      },\n      ...readOnlyToolProps,\n    },\n    peopleService.getUserRelations,\n  );\n\n  // 4. Connect the transport layer and start listening\n  const transport = new StdioServerTransport();\n  await server.connect(transport);\n\n  console.error(\n    `Google Workspace MCP Server is running (using ${separator} for tool names). Listening for requests...`,\n  );\n}\n\nmain().catch((error) => {\n  console.error('A critical error occurred:', error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "workspace-server/src/services/CalendarService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport crypto from 'node:crypto';\nimport { calendar_v3, google } from 'googleapis';\nimport { logToFile } from '../utils/logger';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\nimport { iso8601DateTimeSchema } from '../utils/validation';\nimport {\n  validateCreateEventInput,\n  validateUpdateEventInput,\n} from './CalendarValidation';\nimport { z } from 'zod';\n\n/**\n * Google Drive file attachment for calendar events.\n * Attachments are fully replaced (not appended) when provided.\n */\ninterface EventAttachment {\n  fileUrl: string;\n  title?: string;\n  mimeType?: string;\n}\n\nexport type CalendarEventType =\n  | 'default'\n  | 'focusTime'\n  | 'outOfOffice'\n  | 'workingLocation';\n\nexport type ListEventsEventType = CalendarEventType | 'birthday' | 'fromGmail';\n\nexport interface CreateEventInput {\n  calendarId?: string;\n  summary?: string;\n  description?: string;\n  start: { dateTime?: string; date?: string };\n  end: { dateTime?: string; date?: string };\n  attendees?: string[];\n  sendUpdates?: 'all' | 'externalOnly' | 'none';\n  addGoogleMeet?: boolean;\n  attachments?: EventAttachment[];\n  eventType?: CalendarEventType;\n  focusTimeProperties?: {\n    chatStatus?: 'available' | 'doNotDisturb';\n    autoDeclineMode?:\n      | 'declineNone'\n      | 'declineAllConflictingInvitations'\n      | 'declineOnlyNewConflictingInvitations';\n    declineMessage?: string;\n  };\n  outOfOfficeProperties?: {\n    autoDeclineMode?:\n      | 'declineNone'\n      | 'declineAllConflictingInvitations'\n      | 'declineOnlyNewConflictingInvitations';\n    declineMessage?: string;\n  };\n  workingLocationProperties?: {\n    type: 'homeOffice' | 'officeLocation' | 'customLocation';\n    officeLocation?: { buildingId?: string; label?: string };\n    customLocation?: { label: string };\n  };\n}\n\nexport interface ListEventsInput {\n  calendarId?: string;\n  timeMin?: string;\n  timeMax?: string;\n  attendeeResponseStatus?: string[];\n  eventTypes?: ListEventsEventType[];\n}\n\nexport interface GetEventInput {\n  eventId: string;\n  calendarId?: string;\n}\n\nexport interface DeleteEventInput {\n  eventId: string;\n  calendarId?: string;\n}\n\nexport interface UpdateEventInput {\n  eventId: string;\n  calendarId?: string;\n  summary?: string;\n  description?: string;\n  start?: { dateTime?: string; date?: string };\n  end?: { dateTime?: string; date?: string };\n  attendees?: string[];\n  addGoogleMeet?: boolean;\n  attachments?: EventAttachment[];\n}\n\nexport interface RespondToEventInput {\n  eventId: string;\n  calendarId?: string;\n  responseStatus: 'accepted' | 'declined' | 'tentative';\n  sendNotification?: boolean;\n  responseMessage?: string;\n}\n\nexport interface FindFreeTimeInput {\n  attendees: string[];\n  timeMin: string;\n  timeMax: string;\n  duration: number;\n}\n\nexport class CalendarService {\n  private primaryCalendarId: string | null = null;\n\n  constructor(private authManager: any) {}\n\n  /**\n   * Adds conferenceData and attachments to an event body and its API params.\n   *\n   * IMPORTANT: Attachments are fully REPLACED, not appended. When attachments\n   * are provided, any existing attachments on the event will be removed.\n   */\n  private applyMeetAndAttachments(\n    event: calendar_v3.Schema$Event,\n    params: { conferenceDataVersion?: number; supportsAttachments?: boolean },\n    addGoogleMeet?: boolean,\n    attachments?: EventAttachment[],\n    options?: { allowEmptyAttachments?: boolean },\n  ): void {\n    if (addGoogleMeet) {\n      event.conferenceData = {\n        createRequest: {\n          requestId: crypto.randomUUID(),\n          conferenceSolutionKey: { type: 'hangoutsMeet' },\n        },\n      };\n      params.conferenceDataVersion = 1;\n    }\n    if (\n      attachments &&\n      (attachments.length > 0 || options?.allowEmptyAttachments)\n    ) {\n      event.attachments = attachments.map((a) => ({\n        fileUrl: a.fileUrl,\n        title: a.title,\n        mimeType: a.mimeType,\n      }));\n      params.supportsAttachments = true;\n    }\n  }\n\n  private createValidationErrorResponse(error: unknown) {\n    const errorMessage =\n      error instanceof z.ZodError\n        ? error.issues\n            .map((issue) =>\n              issue.path.length\n                ? `${issue.path.join('.')}: ${issue.message}`\n                : issue.message,\n            )\n            .join('; ')\n        : error instanceof Error\n          ? error.message\n          : 'Validation failed';\n    let helpMessage =\n      'Please use strict ISO 8601 format with seconds and timezone. Examples: 2024-01-15T10:30:00Z (UTC) or 2024-01-15T10:30:00-05:00 (EST)';\n\n    if (\n      error instanceof z.ZodError &&\n      error.issues.some(\n        (issue) =>\n          issue.path.includes('attendees') || issue.message.includes('email'),\n      )\n    ) {\n      helpMessage = 'Please ensure all attendee emails are in a valid format.';\n    }\n\n    return {\n      content: [\n        {\n          type: 'text' as const,\n          text: JSON.stringify({\n            error: 'Invalid input format',\n            details: errorMessage,\n            help: helpMessage,\n          }),\n        },\n      ],\n    };\n  }\n\n  private extractErrorMessage(error: unknown): string {\n    const details = (\n      error as {\n        response?: {\n          data?: {\n            error?: {\n              message?: string;\n              code?: number;\n              errors?: Array<{\n                domain?: string;\n                reason?: string;\n                message?: string;\n                location?: string;\n                locationType?: string;\n              }>;\n            };\n          };\n        };\n      }\n    )?.response?.data?.error;\n\n    if (details) {\n      const topLevelMessage = details.message ?? 'Unknown Error';\n      const code = details.code ? ` (code ${details.code})` : '';\n\n      if (details.errors?.length) {\n        const fieldErrors = details.errors\n          .map((e) => {\n            const context = [e.domain, e.locationType, e.location]\n              .filter(Boolean)\n              .join('.');\n            const identity = [context, e.reason].filter(Boolean).join(' ');\n            return identity ? `${identity}: ${e.message}` : e.message;\n          })\n          .join('; ');\n\n        // If the top-level message is just a generic summary of the first field error,\n        // or if they are identical, just show the field errors to avoid stutter.\n        if (\n          details.errors.length === 1 &&\n          (topLevelMessage === details.errors[0].message ||\n            topLevelMessage.includes(details.errors[0].message ?? ''))\n        ) {\n          return `${fieldErrors}${code}`;\n        }\n\n        return `${topLevelMessage}${code}: ${fieldErrors}`;\n      }\n\n      return `${topLevelMessage}${code}`;\n    }\n\n    return error instanceof Error ? error.message : String(error);\n  }\n\n  private async getCalendar(): Promise<calendar_v3.Calendar> {\n    logToFile('Getting authenticated client for calendar...');\n    const auth = await this.authManager.getAuthenticatedClient();\n    logToFile('Got auth client, creating calendar instance...');\n    const options = { ...gaxiosOptions, auth };\n    return google.calendar({ version: 'v3', ...options });\n  }\n\n  private async getPrimaryCalendarId(): Promise<string> {\n    if (this.primaryCalendarId) {\n      return this.primaryCalendarId;\n    }\n    logToFile('Getting primary calendar ID...');\n    const calendar = await this.getCalendar();\n    const res = await calendar.calendarList.list();\n    const primaryCalendar = res.data.items?.find((c) => c.primary);\n    if (primaryCalendar && primaryCalendar.id) {\n      logToFile(`Found primary calendar: ${primaryCalendar.id}`);\n      this.primaryCalendarId = primaryCalendar.id;\n      return primaryCalendar.id;\n    }\n    logToFile('No primary calendar found, defaulting to \"primary\"');\n    return 'primary';\n  }\n\n  listCalendars = async () => {\n    logToFile('listCalendars called');\n    try {\n      logToFile('Getting calendar instance...');\n      const calendar = await this.getCalendar();\n      logToFile('Making API call to calendar.calendarList.list()...');\n      const res = await calendar.calendarList.list();\n      logToFile(`Found ${res.data.items?.length} calendars.`);\n      const calendars = res.data.items || [];\n      logToFile(\n        `Returning calendar data: ${JSON.stringify(calendars.map((c) => ({ id: c?.id, summary: c?.summary })))}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              calendars.map((c) => ({ id: c?.id, summary: c?.summary })),\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during calendar.list: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  createEvent = async (input: CreateEventInput) => {\n    const {\n      calendarId,\n      description,\n      start,\n      end,\n      attendees,\n      sendUpdates,\n      addGoogleMeet,\n      attachments,\n      eventType,\n      focusTimeProperties,\n      outOfOfficeProperties,\n      workingLocationProperties,\n    } = input;\n\n    // Apply default summary based on event type\n    const summaryDefaults: Record<string, string> = {\n      focusTime: 'Focus Time',\n      outOfOffice: 'Out of Office',\n      workingLocation: 'Working Location',\n    };\n    const summary =\n      input.summary ?? (eventType ? summaryDefaults[eventType] : undefined);\n\n    try {\n      validateCreateEventInput(input);\n    } catch (error) {\n      return this.createValidationErrorResponse(error);\n    }\n\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n    logToFile(`Creating event in calendar: ${finalCalendarId}`);\n    logToFile(`Event summary: ${summary}`);\n    if (eventType) logToFile(`Event type: ${eventType}`);\n    if (description) logToFile(`Event description: ${description}`);\n    logToFile(`Event start: ${start.dateTime || start.date}`);\n    logToFile(`Event end: ${end.dateTime || end.date}`);\n    logToFile(`Event attendees: ${attendees?.join(', ')}`);\n    if (addGoogleMeet) logToFile('Adding Google Meet link');\n    if (attachments?.length)\n      logToFile(`Attachments: ${attachments.length} file(s)`);\n\n    // Determine sendUpdates value\n    let finalSendUpdates = sendUpdates;\n    if (finalSendUpdates === undefined) {\n      finalSendUpdates = attendees?.length ? 'all' : 'none';\n    }\n    if (finalSendUpdates) {\n      logToFile(`Sending updates: ${finalSendUpdates}`);\n    }\n\n    try {\n      const event: calendar_v3.Schema$Event = {\n        summary,\n        description,\n        start,\n        end,\n        attendees: attendees?.map((email) => ({ email })),\n      };\n\n      // Set event type and type-specific properties\n      if (eventType && eventType !== 'default') {\n        event.eventType = eventType;\n      }\n\n      if (eventType === 'focusTime') {\n        event.transparency = 'opaque';\n        event.focusTimeProperties = {\n          chatStatus: focusTimeProperties?.chatStatus ?? 'doNotDisturb',\n          autoDeclineMode:\n            focusTimeProperties?.autoDeclineMode ??\n            'declineOnlyNewConflictingInvitations',\n        };\n        if (focusTimeProperties?.declineMessage !== undefined) {\n          event.focusTimeProperties.declineMessage =\n            focusTimeProperties.declineMessage;\n        }\n      } else if (eventType === 'outOfOffice') {\n        event.transparency = 'opaque';\n        event.outOfOfficeProperties = {\n          autoDeclineMode:\n            outOfOfficeProperties?.autoDeclineMode ??\n            'declineOnlyNewConflictingInvitations',\n        };\n        if (outOfOfficeProperties?.declineMessage !== undefined) {\n          event.outOfOfficeProperties.declineMessage =\n            outOfOfficeProperties.declineMessage;\n        }\n      } else if (eventType === 'workingLocation') {\n        // workingLocationProperties is guaranteed non-null by validation above\n        const wlInput = workingLocationProperties!;\n        event.visibility = 'public';\n        event.transparency = 'transparent';\n\n        const wlProps: calendar_v3.Schema$EventWorkingLocationProperties = {\n          type: wlInput.type,\n        };\n        if (wlInput.type === 'homeOffice') {\n          wlProps.homeOffice = {};\n        } else if (\n          wlInput.type === 'officeLocation' &&\n          wlInput.officeLocation\n        ) {\n          wlProps.officeLocation = {\n            buildingId: wlInput.officeLocation.buildingId,\n            label: wlInput.officeLocation.label,\n          };\n        } else if (\n          wlInput.type === 'customLocation' &&\n          wlInput.customLocation\n        ) {\n          wlProps.customLocation = {\n            label: wlInput.customLocation.label,\n          };\n        }\n        event.workingLocationProperties = wlProps;\n      }\n\n      const calendar = await this.getCalendar();\n      const insertParams: calendar_v3.Params$Resource$Events$Insert = {\n        calendarId: finalCalendarId,\n        requestBody: event,\n        sendUpdates: finalSendUpdates,\n      };\n      this.applyMeetAndAttachments(\n        event,\n        insertParams,\n        addGoogleMeet,\n        attachments,\n        { allowEmptyAttachments: false },\n      );\n\n      const res = await calendar.events.insert(insertParams);\n      logToFile(`Successfully created event: ${res.data.id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(res.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage = this.extractErrorMessage(error);\n      logToFile(`Error during calendar.createEvent: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  listEvents = async (input: ListEventsInput) => {\n    const {\n      calendarId,\n      timeMin = new Date().toISOString(),\n      attendeeResponseStatus = ['accepted', 'tentative', 'needsAction'],\n      eventTypes,\n    } = input;\n\n    let timeMax = input.timeMax;\n    if (!timeMax) {\n      const thirtyDaysFromNow = new Date();\n      thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);\n      timeMax = thirtyDaysFromNow.toISOString();\n    }\n\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n    logToFile(`Listing events for calendar: ${finalCalendarId}`);\n    try {\n      const calendar = await this.getCalendar();\n      const listParams: calendar_v3.Params$Resource$Events$List = {\n        calendarId: finalCalendarId,\n        timeMin,\n        timeMax,\n        singleEvents: true,\n        fields:\n          'items(id,summary,start,end,description,htmlLink,attendees,status,eventType,focusTimeProperties,outOfOfficeProperties,workingLocationProperties)',\n      };\n      if (eventTypes && eventTypes.length > 0) {\n        listParams.eventTypes = eventTypes;\n      }\n      const res = await calendar.events.list(listParams);\n\n      const events = res.data.items\n        ?.filter(\n          (event) =>\n            event.status !== 'cancelled' &&\n            (!!event.summary ||\n              (event.eventType && event.eventType !== 'default')),\n        )\n        .filter((event) => {\n          if (!event.attendees || event.attendees.length === 0) {\n            return true; // No attendees, so we can't filter, include it\n          }\n          if (event.attendees.length === 1 && event.attendees[0].self) {\n            return true; // I'm the only one, always include it\n          }\n          const self = event.attendees.find((a) => a.self);\n          if (!self) {\n            return true; // We are not an attendee, include it\n          }\n          return attendeeResponseStatus.includes(\n            self.responseStatus || 'needsAction',\n          );\n        });\n\n      logToFile(`Found ${events?.length} events after filtering.`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(events),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during calendar.listEvents: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  getEvent = async (input: GetEventInput) => {\n    const { eventId, calendarId } = input;\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n    logToFile(`Getting event ${eventId} from calendar: ${finalCalendarId}`);\n    try {\n      const calendar = await this.getCalendar();\n      const res = await calendar.events.get({\n        calendarId: finalCalendarId,\n        eventId,\n      });\n      logToFile(`Successfully retrieved event: ${res.data.id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(res.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        (error as any).response?.data?.error?.message ||\n        (error instanceof Error ? error.message : String(error));\n      logToFile(`Error during calendar.getEvent: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  deleteEvent = async (input: DeleteEventInput) => {\n    const { eventId, calendarId } = input;\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n    logToFile(`Deleting event ${eventId} from calendar: ${finalCalendarId}`);\n\n    try {\n      const calendar = await this.getCalendar();\n      await calendar.events.delete({\n        calendarId: finalCalendarId,\n        eventId,\n      });\n\n      logToFile(`Successfully deleted event: ${eventId}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              message: `Successfully deleted event ${eventId}`,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        (error as any).response?.data?.error?.message ||\n        (error instanceof Error ? error.message : String(error));\n      logToFile(`Error during calendar.deleteEvent: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  updateEvent = async (input: UpdateEventInput) => {\n    const {\n      eventId,\n      calendarId,\n      summary,\n      description,\n      start,\n      end,\n      attendees,\n      addGoogleMeet,\n      attachments,\n    } = input;\n\n    try {\n      validateUpdateEventInput(input);\n    } catch (error) {\n      return this.createValidationErrorResponse(error);\n    }\n\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n    logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`);\n    if (addGoogleMeet) logToFile('Adding Google Meet link');\n    if (attachments?.length)\n      logToFile(`Attachments: ${attachments.length} file(s)`);\n\n    try {\n      const calendar = await this.getCalendar();\n      const requestBody: calendar_v3.Schema$Event = {};\n      if (summary !== undefined) requestBody.summary = summary;\n      if (description !== undefined) requestBody.description = description;\n      if (start) requestBody.start = start;\n      if (end) requestBody.end = end;\n      if (attendees !== undefined)\n        requestBody.attendees = attendees.map((email) => ({ email }));\n\n      const updateParams: calendar_v3.Params$Resource$Events$Patch = {\n        calendarId: finalCalendarId,\n        eventId,\n        requestBody,\n      };\n      this.applyMeetAndAttachments(\n        requestBody,\n        updateParams,\n        addGoogleMeet,\n        attachments,\n        { allowEmptyAttachments: true },\n      );\n\n      const res = await calendar.events.patch(updateParams);\n\n      logToFile(`Successfully updated event: ${res.data.id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(res.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage = this.extractErrorMessage(error);\n      logToFile(`Error during calendar.updateEvent: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  respondToEvent = async (input: RespondToEventInput) => {\n    const {\n      eventId,\n      calendarId,\n      responseStatus,\n      sendNotification = true,\n      responseMessage,\n    } = input;\n    const finalCalendarId = calendarId || (await this.getPrimaryCalendarId());\n\n    logToFile(\n      `Responding to event ${eventId} in calendar: ${finalCalendarId} with status: ${responseStatus}`,\n    );\n    if (responseMessage) {\n      logToFile(`Response message: ${responseMessage}`);\n    }\n\n    try {\n      const calendar = await this.getCalendar();\n\n      // First, get the current event to find the attendee entry\n      const event = await calendar.events.get({\n        calendarId: finalCalendarId,\n        eventId,\n      });\n\n      if (!event.data.attendees || event.data.attendees.length === 0) {\n        logToFile('Event has no attendees');\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({ error: 'Event has no attendees' }),\n            },\n          ],\n        };\n      }\n\n      // Find the current user's attendee entry\n      const selfAttendee = event.data.attendees.find((a) => a.self === true);\n      if (!selfAttendee) {\n        logToFile('User is not an attendee of this event');\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                error: 'You are not an attendee of this event',\n              }),\n            },\n          ],\n        };\n      }\n\n      // Update the response status for the current user\n      selfAttendee.responseStatus = responseStatus;\n      if (responseMessage !== undefined) {\n        selfAttendee.comment = responseMessage;\n      }\n\n      // Patch the event with the updated attendee list\n      const res = await calendar.events.patch({\n        calendarId: finalCalendarId,\n        eventId,\n        sendNotifications: sendNotification,\n        requestBody: {\n          attendees: event.data.attendees,\n        },\n      });\n\n      logToFile(\n        `Successfully responded to event: ${res.data.id} with status: ${responseStatus}`,\n      );\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              eventId: res.data.id,\n              summary: res.data.summary,\n              responseStatus,\n              message: `Successfully ${responseStatus} the meeting invitation${responseMessage ? ' with message' : ''}`,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during calendar.respondToEvent: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  findFreeTime = async (input: FindFreeTimeInput) => {\n    const { attendees, timeMin, timeMax, duration } = input;\n\n    // Validate datetime formats\n    try {\n      iso8601DateTimeSchema.parse(timeMin);\n      iso8601DateTimeSchema.parse(timeMax);\n      // Note: attendees can include 'me' as a special value, so we don't validate as emails\n    } catch (error) {\n      return this.createValidationErrorResponse(error);\n    }\n\n    logToFile(`Finding free time for attendees: ${attendees.join(', ')}`);\n    logToFile(`Time range: ${timeMin} - ${timeMax}`);\n    logToFile(`Duration: ${duration} minutes`);\n\n    try {\n      const calendar = await this.getCalendar();\n      const items = await Promise.all(\n        attendees.map(async (email) => {\n          if (email === 'me') {\n            const primaryId = await this.getPrimaryCalendarId();\n            return { id: primaryId };\n          }\n          return { id: email };\n        }),\n      );\n\n      const res = await calendar.freebusy.query({\n        requestBody: {\n          items,\n          timeMin,\n          timeMax,\n        },\n      });\n\n      const busyTimes = Object.values(res.data.calendars || {}).flatMap(\n        (cal) => cal.busy || [],\n      );\n      if (busyTimes.length === 0) {\n        logToFile(\n          'No busy times found, returning the start of the time range.',\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                start: timeMin,\n                end: new Date(\n                  new Date(timeMin).getTime() + duration * 60000,\n                ).toISOString(),\n              }),\n            },\n          ],\n        };\n      }\n\n      // Sort and merge overlapping busy intervals for better performance\n      const sortedBusyTimes = busyTimes\n        .filter((busy) => busy.start && busy.end)\n        .map((busy) => ({\n          start: new Date(busy.start!).getTime(),\n          end: new Date(busy.end!).getTime(),\n        }))\n        .sort((a, b) => a.start - b.start);\n\n      const mergedBusyTimes: { start: number; end: number }[] = [];\n      for (const busy of sortedBusyTimes) {\n        if (mergedBusyTimes.length === 0) {\n          mergedBusyTimes.push(busy);\n        } else {\n          const last = mergedBusyTimes[mergedBusyTimes.length - 1];\n          if (busy.start <= last.end) {\n            // Overlapping or adjacent intervals - merge them\n            last.end = Math.max(last.end, busy.end);\n          } else {\n            mergedBusyTimes.push(busy);\n          }\n        }\n      }\n\n      const startTime = new Date(timeMin).getTime();\n      const endTime = new Date(timeMax).getTime();\n      const durationMs = duration * 60000;\n\n      // If no busy times, return the start of the range\n      if (mergedBusyTimes.length === 0) {\n        const slotEnd = new Date(startTime + durationMs);\n        logToFile(\n          `No busy times, found free time: ${timeMin} - ${slotEnd.toISOString()}`,\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                start: timeMin,\n                end: slotEnd.toISOString(),\n              }),\n            },\n          ],\n        };\n      }\n\n      // Check if we can fit the meeting before the first busy slot\n      if (startTime + durationMs <= mergedBusyTimes[0].start) {\n        const slotEnd = new Date(startTime + durationMs);\n        logToFile(`Found free time: ${timeMin} - ${slotEnd.toISOString()}`);\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                start: timeMin,\n                end: slotEnd.toISOString(),\n              }),\n            },\n          ],\n        };\n      }\n\n      // Check gaps between busy slots\n      for (let i = 0; i < mergedBusyTimes.length - 1; i++) {\n        const gapStart = mergedBusyTimes[i].end;\n        const gapEnd = mergedBusyTimes[i + 1].start;\n\n        if (gapEnd - gapStart >= durationMs) {\n          const slotStart = new Date(gapStart);\n          const slotEnd = new Date(gapStart + durationMs);\n          logToFile(\n            `Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`,\n          );\n          return {\n            content: [\n              {\n                type: 'text' as const,\n                text: JSON.stringify({\n                  start: slotStart.toISOString(),\n                  end: slotEnd.toISOString(),\n                }),\n              },\n            ],\n          };\n        }\n      }\n\n      // Check if we can fit after the last busy slot\n      const lastBusyEnd = mergedBusyTimes[mergedBusyTimes.length - 1].end;\n      if (lastBusyEnd + durationMs <= endTime) {\n        const slotStart = new Date(lastBusyEnd);\n        const slotEnd = new Date(lastBusyEnd + durationMs);\n        logToFile(\n          `Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`,\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                start: slotStart.toISOString(),\n                end: slotEnd.toISOString(),\n              }),\n            },\n          ],\n        };\n      }\n\n      logToFile('No available free time found');\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: 'No available free time found' }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during calendar.findFreeTime: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/CalendarValidation.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { z } from 'zod';\nimport { emailArraySchema, iso8601DateTimeSchema } from '../utils/validation';\nimport type {\n  CalendarEventType,\n  CreateEventInput,\n  UpdateEventInput,\n} from './CalendarService';\n\ntype EventDateInput = {\n  dateTime?: string | null;\n  date?: string | null;\n};\n\ntype WorkingLocationValidationInput = {\n  type?: string | null;\n  officeLocation?: unknown;\n  customLocation?: unknown;\n};\n\ntype CompleteEventValidationInput = {\n  summary?: string | null;\n  start: EventDateInput;\n  end: EventDateInput;\n  attendees?: string[];\n  eventType?: CalendarEventType | null;\n  workingLocationProperties?: WorkingLocationValidationInput | null;\n};\n\nconst isoDateSchema = z.string().refine(\n  (val) => {\n    if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(val)) return false;\n    const parsed = new Date(`${val}T00:00:00Z`);\n    if (Number.isNaN(parsed.getTime())) return false;\n    return parsed.toISOString().slice(0, 10) === val;\n  },\n  {\n    message: 'Invalid date format. Expected YYYY-MM-DD',\n  },\n);\n\nfunction createIssue(path: (string | number)[], message: string): z.ZodError {\n  return new z.ZodError([\n    {\n      code: 'custom',\n      message,\n      path,\n    },\n  ]);\n}\n\nfunction validateExclusiveDateField(\n  fieldName: 'start' | 'end',\n  fieldValue: EventDateInput,\n): void {\n  const hasDateTime = !!fieldValue.dateTime;\n  const hasDate = !!fieldValue.date;\n\n  if ((!hasDateTime && !hasDate) || (hasDateTime && hasDate)) {\n    throw createIssue(\n      [fieldName],\n      `${fieldName} must have exactly one of \"dateTime\" (for timed events) or \"date\" (for all-day events)`,\n    );\n  }\n}\n\nfunction validateOptionalExclusiveDateField(\n  fieldName: 'start' | 'end',\n  fieldValue?: EventDateInput,\n): void {\n  if (!fieldValue) {\n    return;\n  }\n\n  const hasDateTime = !!fieldValue.dateTime;\n  const hasDate = !!fieldValue.date;\n\n  if ((!hasDateTime && !hasDate) || (hasDateTime && hasDate)) {\n    throw createIssue(\n      [fieldName],\n      `${fieldName} must have exactly one of \"dateTime\" (for timed events) or \"date\" (for all-day events)`,\n    );\n  }\n}\n\nfunction validateDateFieldFormats(\n  fieldName: 'start' | 'end',\n  field: EventDateInput,\n) {\n  if (field.dateTime) {\n    iso8601DateTimeSchema.parse(field.dateTime);\n  }\n  if (field.date) {\n    isoDateSchema.parse(field.date);\n  }\n}\n\nfunction validateWorkingLocationProperties(\n  workingLocationProperties?: WorkingLocationValidationInput | null,\n): void {\n  if (!workingLocationProperties) {\n    throw createIssue(\n      ['workingLocationProperties'],\n      'workingLocationProperties is required when eventType is \"workingLocation\"',\n    );\n  }\n\n  if (\n    workingLocationProperties.type === 'officeLocation' &&\n    !workingLocationProperties.officeLocation\n  ) {\n    throw createIssue(\n      ['workingLocationProperties', 'officeLocation'],\n      'officeLocation is required when workingLocationProperties.type is \"officeLocation\"',\n    );\n  }\n\n  if (\n    workingLocationProperties.type === 'customLocation' &&\n    !workingLocationProperties.customLocation\n  ) {\n    throw createIssue(\n      ['workingLocationProperties', 'customLocation'],\n      'customLocation is required when workingLocationProperties.type is \"customLocation\"',\n    );\n  }\n}\n\nfunction addDays(date: string, days: number): string {\n  const parsed = new Date(`${date}T00:00:00Z`);\n  parsed.setUTCDate(parsed.getUTCDate() + days);\n  return parsed.toISOString().slice(0, 10);\n}\n\nfunction validateWorkingLocationDuration(\n  input: CompleteEventValidationInput,\n): void {\n  if (\n    input.eventType === 'workingLocation' &&\n    input.start.date &&\n    input.end.date\n  ) {\n    if (input.end.date < input.start.date) {\n      throw createIssue(\n        ['start', 'end'],\n        'end.date must be on or after start.date',\n      );\n    }\n    if (addDays(input.start.date, 1) !== input.end.date) {\n      throw createIssue(\n        ['start', 'end'],\n        'all-day workingLocation events must span exactly one day',\n      );\n    }\n  }\n}\n\nfunction validateCompleteEventInput(input: CompleteEventValidationInput): void {\n  validateExclusiveDateField('start', input.start);\n  validateExclusiveDateField('end', input.end);\n  validateDateFieldFormats('start', input.start);\n  validateDateFieldFormats('end', input.end);\n\n  if ((!input.eventType || input.eventType === 'default') && !input.summary) {\n    throw createIssue(['summary'], 'summary is required for regular events');\n  }\n\n  if (\n    (input.eventType === 'focusTime' || input.eventType === 'outOfOffice') &&\n    (input.start.date || input.end.date)\n  ) {\n    throw createIssue(\n      ['start', 'end'],\n      `${input.eventType} events cannot be all-day events; use dateTime instead of date`,\n    );\n  }\n\n  if (input.eventType === 'workingLocation') {\n    validateWorkingLocationProperties(input.workingLocationProperties);\n    validateWorkingLocationDuration(input);\n  }\n\n  if (input.attendees) {\n    emailArraySchema.parse(input.attendees);\n  }\n}\n\nexport function validateCreateEventInput(input: CreateEventInput): void {\n  validateCompleteEventInput({\n    summary: input.summary,\n    start: input.start,\n    end: input.end,\n    attendees: input.attendees,\n    eventType: input.eventType,\n    workingLocationProperties: input.workingLocationProperties,\n  });\n}\n\nexport function validateUpdateEventInput(input: UpdateEventInput): void {\n  validateOptionalExclusiveDateField('start', input.start);\n  validateOptionalExclusiveDateField('end', input.end);\n\n  if (input.start) {\n    validateDateFieldFormats('start', input.start);\n  }\n  if (input.end) {\n    validateDateFieldFormats('end', input.end);\n  }\n  if (input.attendees) {\n    emailArraySchema.parse(input.attendees);\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/services/ChatService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, chat_v1, people_v1 } from 'googleapis';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\n\ninterface GetMessagesParams {\n  spaceName: string;\n  unreadOnly?: boolean;\n  pageSize?: number;\n  pageToken?: string;\n  orderBy?: string;\n  threadName?: string;\n}\n\nexport class ChatService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getChatClient(): Promise<chat_v1.Chat> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.chat({ version: 'v1', ...options });\n  }\n\n  private async getPeopleClient(): Promise<people_v1.People> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.people({ version: 'v1', ...options });\n  }\n\n  private async _setupDmSpace(email: string): Promise<chat_v1.Schema$Space> {\n    const person = {\n      name: `users/${email}`,\n      type: 'HUMAN',\n    };\n\n    const chat = await this.getChatClient();\n    const setupResponse = await chat.spaces.setup({\n      requestBody: {\n        space: {\n          spaceType: 'DIRECT_MESSAGE',\n        },\n        memberships: [\n          {\n            member: person,\n          },\n        ],\n      },\n    });\n\n    const space = setupResponse.data;\n    if (!space) {\n      throw new Error('Could not find or create a DM space.');\n    }\n    return space;\n  }\n\n  public listSpaces = async () => {\n    logToFile('Listing chat spaces');\n    try {\n      const chat = await this.getChatClient();\n      const res = await chat.spaces.list({});\n      const spaces = res.data.spaces || [];\n      logToFile(`Successfully listed ${spaces.length} chat spaces.`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(spaces),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.listSpaces: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while listing chat spaces.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public sendMessage = async ({\n    spaceName,\n    message,\n    threadName,\n  }: {\n    spaceName: string;\n    message: string;\n    threadName?: string;\n  }) => {\n    logToFile(\n      `Sending message to space: ${spaceName}${threadName ? ` in thread: ${threadName}` : ''}`,\n    );\n    try {\n      const chat = await this.getChatClient();\n      const response = await chat.spaces.messages.create({\n        parent: spaceName,\n        messageReplyOption: threadName\n          ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD'\n          : undefined,\n        requestBody: {\n          text: message,\n          thread: threadName ? { name: threadName } : undefined,\n        },\n      });\n      logToFile(`Successfully sent message to space: ${spaceName}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(response.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.sendMessage: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while sending the message.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public findSpaceByName = async ({ displayName }: { displayName: string }) => {\n    logToFile(`Finding space with display name: ${displayName}`);\n    try {\n      const chat = await this.getChatClient();\n      // The Chat API's spaces.list method does not support filtering by\n      // displayName on the server. We must fetch all spaces and filter locally.\n      let pageToken: string | undefined = undefined;\n      let allSpaces: chat_v1.Schema$Space[] = [];\n\n      do {\n        const res: any = await chat.spaces.list({ pageToken });\n        const spaces = res.data.spaces || [];\n        allSpaces = allSpaces.concat(spaces);\n        pageToken = res.data.nextPageToken || undefined;\n      } while (pageToken);\n\n      const foundSpaces = allSpaces.filter(\n        (space) => space.displayName === displayName,\n      );\n\n      if (foundSpaces.length > 0) {\n        logToFile(\n          `Found ${foundSpaces.length} space(s) with display name: ${displayName}`,\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify(foundSpaces),\n            },\n          ],\n        };\n      } else {\n        logToFile(`No space found with display name: ${displayName}`);\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                error: `No space found with display name: ${displayName}`,\n              }),\n            },\n          ],\n        };\n      }\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.findSpaceByName: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while finding the space.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getMessages = async ({\n    spaceName,\n    unreadOnly,\n    pageSize,\n    pageToken,\n    orderBy,\n    threadName,\n  }: GetMessagesParams) => {\n    logToFile(`Listing messages for space: ${spaceName}`);\n    try {\n      const chat = await this.getChatClient();\n      const filters: string[] = [];\n\n      if (threadName) {\n        filters.push(`thread.name = \"${threadName}\"`);\n      }\n\n      if (unreadOnly) {\n        const people = await this.getPeopleClient();\n        const person = await people.people.get({\n          resourceName: 'people/me',\n          personFields: 'metadata',\n        });\n\n        const userId = person.data.metadata?.sources?.find(\n          (s) => s.type === 'PROFILE',\n        )?.id;\n\n        if (!userId) {\n          throw new Error('Could not determine user ID.');\n        }\n        const userMemberName = `users/${userId}`;\n\n        const membersRes = await chat.spaces.members.list({\n          parent: spaceName,\n        });\n        // Type assertion needed due to incomplete type definitions\n        const memberships = (membersRes.data as any).memberships || [];\n        const currentUserMember = memberships.find(\n          (m: any) => m.member?.name === userMemberName,\n        );\n\n        const lastReadTime = currentUserMember?.lastReadTime;\n\n        if (lastReadTime) {\n          filters.push(`createTime > \"${lastReadTime}\"`);\n        } else {\n          logToFile(`No last read time found for user in space: ${spaceName}`);\n        }\n      }\n\n      const filter = filters.join(' AND ');\n\n      const res = await chat.spaces.messages.list({\n        parent: spaceName,\n        filter: filter ? filter : undefined,\n        pageSize,\n        pageToken,\n        orderBy,\n      });\n\n      const messages = res.data.messages || [];\n      const logMessage = unreadOnly\n        ? `Successfully listed ${messages.length} unread messages for space: ${spaceName}`\n        : `Successfully listed ${messages.length} messages for space: ${spaceName}`;\n      logToFile(logMessage);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              messages,\n              nextPageToken: res.data.nextPageToken,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.getMessages: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while listing messages.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public sendDm = async ({\n    email,\n    message,\n    threadName,\n  }: {\n    email: string;\n    message: string;\n    threadName?: string;\n  }) => {\n    logToFile(\n      `chat.sendDm called with: email=${email}, message=${message}${threadName ? `, threadName=${threadName}` : ''}`,\n    );\n    try {\n      const space = await this._setupDmSpace(email);\n      const spaceName = space.name;\n\n      if (!spaceName) {\n        throw new Error('Could not determine the space name for the DM.');\n      }\n\n      const chat = await this.getChatClient();\n      // Send the message to the DM space.\n      const messageResponse = await chat.spaces.messages.create({\n        parent: spaceName,\n        messageReplyOption: threadName\n          ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD'\n          : undefined,\n        requestBody: {\n          text: message,\n          thread: threadName ? { name: threadName } : undefined,\n        },\n      });\n\n      logToFile(`Successfully sent DM to: ${email}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(messageResponse.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.sendDm: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while sending the DM.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public findDmByEmail = async ({ email }: { email: string }) => {\n    logToFile(`Finding DM space with user: ${email}`);\n    try {\n      const space = await this._setupDmSpace(email);\n      logToFile(`Found or created DM space: ${space.name}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(space),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.findDmByEmail: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while finding the DM space.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public listThreads = async ({\n    spaceName,\n    pageSize,\n    pageToken,\n  }: {\n    spaceName: string;\n    pageSize?: number;\n    pageToken?: string;\n  }) => {\n    logToFile(`Listing threads for space: ${spaceName}`);\n    try {\n      const chat = await this.getChatClient();\n      const res = await chat.spaces.messages.list({\n        parent: spaceName,\n        pageSize,\n        pageToken,\n        orderBy: 'createTime desc',\n      });\n\n      const messages = res.data.messages || [];\n      const threads: chat_v1.Schema$Message[] = [];\n      const threadIds = new Set<string>();\n\n      for (const message of messages) {\n        if (message.thread?.name && !threadIds.has(message.thread.name)) {\n          threads.push(message);\n          threadIds.add(message.thread.name);\n        }\n      }\n\n      logToFile(\n        `Successfully listed ${threads.length} threads for space: ${spaceName}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              threads,\n              nextPageToken: res.data.nextPageToken,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.listThreads: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while listing threads.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n\n  public setUpSpace = async ({\n    displayName,\n    userNames,\n  }: {\n    displayName: string;\n    userNames: string[];\n  }) => {\n    logToFile(`Creating space with display name: ${displayName}`);\n    try {\n      const memberships = userNames.map((userName) => ({\n        member: {\n          name: userName,\n          type: 'HUMAN',\n        },\n      }));\n\n      const chat = await this.getChatClient();\n      const response = await chat.spaces.setup({\n        requestBody: {\n          space: {\n            spaceType: 'SPACE',\n            displayName,\n          },\n          memberships: memberships,\n        },\n      });\n      logToFile(`Successfully created space: ${response.data.name}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(response.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during chat.createSpace: ${errorMessage}`);\n      if (error instanceof Error && error.stack) {\n        logToFile(`Stack trace: ${error.stack}`);\n      }\n      logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              error: 'An error occurred while creating the space.',\n              details: errorMessage,\n            }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/DocsService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, docs_v1 } from 'googleapis';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { extractDocId } from '../utils/IdUtils';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\nimport { extractDocumentId as validateAndExtractDocId } from '../utils/validation';\n\n// Field mask for documents.get when reading tab content. Selects only the\n// structural fields we use; broader masks like 'tabs' alone trigger\n// \"comment-specific fields\" errors when combined with includeTabsContent.\nexport const TABS_FIELD_MASK =\n  'tabs(tabProperties,documentTab(body,headers,footers,footnotes))';\n\ninterface BaseDocsSuggestion {\n  text: string;\n  startIndex?: number;\n  endIndex?: number;\n}\n\ninterface DocsInsertionSuggestion extends BaseDocsSuggestion {\n  type: 'insertion';\n  suggestionIds: string[];\n}\n\ninterface DocsDeletionSuggestion extends BaseDocsSuggestion {\n  type: 'deletion';\n  suggestionIds: string[];\n}\n\ninterface DocsStyleChangeSuggestion extends BaseDocsSuggestion {\n  type: 'styleChange';\n  suggestionIds: string[];\n  textStyle?: docs_v1.Schema$TextStyle;\n}\n\ninterface DocsParagraphStyleChangeSuggestion extends BaseDocsSuggestion {\n  type: 'paragraphStyleChange';\n  suggestionIds: string[];\n  namedStyleType?: string;\n}\n\ntype DocsSuggestion =\n  | DocsInsertionSuggestion\n  | DocsDeletionSuggestion\n  | DocsStyleChangeSuggestion\n  | DocsParagraphStyleChangeSuggestion;\n\nexport class DocsService {\n  /**\n   * Recursively flattens a tab tree into a single array,\n   * so that nested (child) tabs are included alongside top-level ones.\n   */\n  private _flattenTabs(tabs: docs_v1.Schema$Tab[]): docs_v1.Schema$Tab[] {\n    return tabs.flatMap((tab) => {\n      const children = tab.childTabs ? this._flattenTabs(tab.childTabs) : [];\n      return [tab, ...children];\n    });\n  }\n\n  constructor(private authManager: AuthManager) {}\n\n  private async getDocsClient(): Promise<docs_v1.Docs> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.docs({ version: 'v1', ...options });\n  }\n\n  public getSuggestions = async ({ documentId }: { documentId: string }) => {\n    logToFile(\n      `[DocsService] Starting getSuggestions for document: ${documentId}`,\n    );\n    try {\n      const id = extractDocId(documentId) || documentId;\n      const docs = await this.getDocsClient();\n      const res = await docs.documents.get({\n        documentId: id,\n        suggestionsViewMode: 'SUGGESTIONS_INLINE',\n        fields: 'title,body',\n      });\n\n      const suggestions: DocsSuggestion[] = this._extractSuggestions(\n        res.data.body,\n      );\n\n      logToFile(\n        `[DocsService] Found ${suggestions.length} suggestions for document: ${id}`,\n      );\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              { title: res.data.title, suggestions },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[DocsService] Error during docs.getSuggestions: ${errorMessage}`,\n      );\n      return {\n        isError: true,\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private _extractSuggestions(\n    body: docs_v1.Schema$Body | undefined | null,\n  ): DocsSuggestion[] {\n    const suggestions: DocsSuggestion[] = [];\n    if (!body?.content) {\n      return suggestions;\n    }\n\n    const processElements = (\n      elements: docs_v1.Schema$StructuralElement[] | undefined,\n    ) => {\n      elements?.forEach((element) => {\n        if (element.paragraph) {\n          // Handle paragraph-level style suggestions\n          if (element.paragraph.suggestedParagraphStyleChanges) {\n            for (const [suggestionId, suggestion] of Object.entries(\n              element.paragraph.suggestedParagraphStyleChanges,\n            )) {\n              suggestions.push({\n                type: 'paragraphStyleChange',\n                text: this._getParagraphText(element.paragraph),\n                suggestionIds: [suggestionId],\n                namedStyleType:\n                  suggestion?.paragraphStyle?.namedStyleType ?? undefined,\n                startIndex: element.startIndex ?? undefined,\n                endIndex: element.endIndex ?? undefined,\n              });\n            }\n          }\n\n          // Handle text-run-level suggestions within the paragraph\n          element.paragraph.elements?.forEach((pElement) => {\n            if (pElement.textRun) {\n              const baseSuggestion = {\n                text: pElement.textRun.content || '',\n                startIndex: pElement.startIndex ?? undefined,\n                endIndex: pElement.endIndex ?? undefined,\n              };\n\n              if (pElement.textRun.suggestedInsertionIds) {\n                suggestions.push({\n                  ...baseSuggestion,\n                  type: 'insertion' as const,\n                  suggestionIds: pElement.textRun.suggestedInsertionIds,\n                });\n              }\n              if (pElement.textRun.suggestedDeletionIds) {\n                suggestions.push({\n                  ...baseSuggestion,\n                  type: 'deletion' as const,\n                  suggestionIds: pElement.textRun.suggestedDeletionIds,\n                });\n              }\n              if (pElement.textRun.suggestedTextStyleChanges) {\n                suggestions.push({\n                  ...baseSuggestion,\n                  type: 'styleChange' as const,\n                  suggestionIds: Object.keys(\n                    pElement.textRun.suggestedTextStyleChanges,\n                  ),\n                  textStyle: pElement.textRun.textStyle,\n                });\n              }\n            }\n          });\n        } else if (element.table) {\n          element.table.tableRows?.forEach((row) => {\n            row.tableCells?.forEach((cell) => {\n              processElements(cell.content);\n            });\n          });\n        }\n      });\n    };\n\n    processElements(body.content);\n    return suggestions;\n  }\n\n  private _getParagraphText(\n    paragraph: docs_v1.Schema$Paragraph | undefined | null,\n  ): string {\n    if (!paragraph?.elements) {\n      return '';\n    }\n    return paragraph.elements\n      .map((pElement) => pElement.textRun?.content || '')\n      .join('');\n  }\n\n  public create = async ({\n    title,\n    content,\n  }: {\n    title: string;\n    content?: string;\n  }) => {\n    logToFile(\n      `[DocsService] Starting create with title: ${title}, content: ${content ? 'true' : 'false'}`,\n    );\n    try {\n      logToFile('[DocsService] Calling docs.documents.create');\n      const docs = await this.getDocsClient();\n      const doc = await docs.documents.create({\n        requestBody: { title },\n      });\n      logToFile('[DocsService] docs.documents.create finished');\n      const documentId = doc.data.documentId!;\n      const docTitle = doc.data.title!;\n\n      // Insert content if provided\n      if (content) {\n        logToFile('[DocsService] Inserting content into new doc');\n        await docs.documents.batchUpdate({\n          documentId,\n          requestBody: {\n            requests: [\n              {\n                insertText: {\n                  location: { index: 1 },\n                  text: content,\n                },\n              },\n            ],\n          },\n        });\n        logToFile('[DocsService] Content insertion finished');\n      }\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              documentId,\n              title: docTitle,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during docs.create: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public writeText = async ({\n    documentId,\n    text,\n    position = 'end',\n    tabId,\n  }: {\n    documentId: string;\n    text: string;\n    position?: string;\n    tabId?: string;\n  }) => {\n    logToFile(\n      `[DocsService] Starting writeText for document: ${documentId}, position: ${position}, tabId: ${tabId}`,\n    );\n    try {\n      const id = extractDocId(documentId) || documentId;\n      const docs = await this.getDocsClient();\n\n      // Optimize: when appending to the main body, omit location to skip\n      // an extra documents.get API call — the Docs API auto-appends.\n      if (position === 'end' && !tabId) {\n        await docs.documents.batchUpdate({\n          documentId: id,\n          requestBody: {\n            requests: [{ insertText: { text } }],\n          },\n        });\n      } else {\n        let index: number;\n\n        if (position === 'beginning') {\n          index = 1;\n        } else if (position === 'end') {\n          // Discover the end index by reading the document (required for tabs)\n          const res = await docs.documents.get({\n            documentId: id,\n            fields: TABS_FIELD_MASK,\n            includeTabsContent: true,\n          });\n\n          const tabs = this._flattenTabs(res.data.tabs || []);\n          let content: docs_v1.Schema$StructuralElement[] | undefined;\n\n          if (tabId) {\n            const tab = tabs.find((t) => t.tabProperties?.tabId === tabId);\n            if (!tab) {\n              throw new Error(`Tab with ID ${tabId} not found.`);\n            }\n            content = tab.documentTab?.body?.content;\n          } else if (tabs.length > 0) {\n            content = tabs[0].documentTab?.body?.content;\n          }\n\n          const lastElement = content?.[content.length - 1];\n          const endIndex = lastElement?.endIndex || 1;\n          index = Math.max(1, endIndex - 1);\n        } else {\n          // Treat as a numeric index\n          index = parseInt(position, 10);\n          if (isNaN(index) || index < 1) {\n            throw new Error(\n              `Invalid position: \"${position}\". Use \"beginning\", \"end\", or a positive integer index.`,\n            );\n          }\n        }\n\n        await docs.documents.batchUpdate({\n          documentId: id,\n          requestBody: {\n            requests: [\n              {\n                insertText: {\n                  location: {\n                    index,\n                    tabId: tabId,\n                  },\n                  text,\n                },\n              },\n            ],\n          },\n        });\n      }\n\n      logToFile(`[DocsService] Finished writeText for document: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: `Successfully wrote text to document ${id} at position ${position}`,\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[DocsService] Error during docs.writeText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private static readonly HEADING_STYLES: Record<string, string> = {\n    heading1: 'HEADING_1',\n    heading2: 'HEADING_2',\n    heading3: 'HEADING_3',\n    heading4: 'HEADING_4',\n    heading5: 'HEADING_5',\n    heading6: 'HEADING_6',\n    normalText: 'NORMAL_TEXT',\n  };\n\n  private static readonly TEXT_STYLES: Record<string, object> = {\n    bold: { bold: true },\n    italic: { italic: true },\n    underline: { underline: true },\n    strikethrough: { strikethrough: true },\n  };\n\n  public formatText = async ({\n    documentId,\n    formats,\n    tabId,\n  }: {\n    documentId: string;\n    formats: {\n      startIndex: number;\n      endIndex: number;\n      style: string;\n      url?: string;\n    }[];\n    tabId?: string;\n  }) => {\n    logToFile(\n      `[DocsService] Starting formatText for document: ${documentId}, ${formats.length} format(s)`,\n    );\n    try {\n      const id = extractDocId(documentId) || documentId;\n      const requests: docs_v1.Schema$Request[] = [];\n\n      for (const format of formats) {\n        const range = {\n          startIndex: format.startIndex,\n          endIndex: format.endIndex,\n          tabId: tabId,\n        };\n\n        const headingStyle =\n          DocsService.HEADING_STYLES[format.style.toLowerCase()];\n        if (headingStyle) {\n          requests.push({\n            updateParagraphStyle: {\n              range,\n              paragraphStyle: {\n                namedStyleType: headingStyle,\n              },\n              fields: 'namedStyleType',\n            },\n          });\n          continue;\n        }\n\n        const textStyle = DocsService.TEXT_STYLES[format.style.toLowerCase()];\n        if (textStyle) {\n          requests.push({\n            updateTextStyle: {\n              range,\n              textStyle,\n              fields: Object.keys(textStyle).join(','),\n            },\n          });\n          continue;\n        }\n\n        if (format.style.toLowerCase() === 'code') {\n          requests.push({\n            updateTextStyle: {\n              range,\n              textStyle: {\n                weightedFontFamily: {\n                  fontFamily: 'Courier New',\n                },\n              },\n              fields: 'weightedFontFamily',\n            },\n          });\n          continue;\n        }\n\n        if (format.style.toLowerCase() === 'link' && format.url) {\n          requests.push({\n            updateTextStyle: {\n              range,\n              textStyle: {\n                link: {\n                  url: format.url,\n                },\n              },\n              fields: 'link',\n            },\n          });\n          continue;\n        }\n\n        logToFile(`[DocsService] Unknown format style: ${format.style}`);\n      }\n\n      if (requests.length === 0) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: 'No valid formatting requests to apply.',\n            },\n          ],\n        };\n      }\n\n      const docs = await this.getDocsClient();\n      await docs.documents.batchUpdate({\n        documentId: id,\n        requestBody: { requests },\n      });\n\n      logToFile(\n        `[DocsService] Finished formatText for document: ${id}, applied ${requests.length} format(s)`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: `Successfully applied ${requests.length} formatting change(s) to document ${id}`,\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[DocsService] Error during docs.formatText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getText = async ({\n    documentId,\n    tabId,\n  }: {\n    documentId: string;\n    tabId?: string;\n  }) => {\n    logToFile(\n      `[DocsService] Starting getText for document: ${documentId}, tabId: ${tabId}`,\n    );\n    try {\n      // Validate and extract document ID\n      const id = validateAndExtractDocId(documentId);\n      const docs = await this.getDocsClient();\n      const res = await docs.documents.get({\n        documentId: id,\n        fields: `title,${TABS_FIELD_MASK}`,\n        includeTabsContent: true,\n        suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',\n      });\n\n      const docTitle = res.data.title;\n      const tabs = this._flattenTabs(res.data.tabs || []);\n\n      // If tabId is provided, try to find it\n      if (tabId) {\n        const tab = tabs.find((t) => t.tabProperties?.tabId === tabId);\n        if (!tab) {\n          throw new Error(`Tab with ID ${tabId} not found.`);\n        }\n\n        const content = tab.documentTab?.body?.content;\n        if (!content) {\n          return {\n            content: [\n              {\n                type: 'text' as const,\n                text: '',\n              },\n            ],\n          };\n        }\n\n        let text = '';\n        if (docTitle) {\n          text += `Document Title: ${docTitle}\\n\\n`;\n        }\n        content.forEach((element) => {\n          text += this._readStructuralElement(element);\n        });\n\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: text,\n            },\n          ],\n        };\n      }\n\n      // If no tabId provided\n      if (tabs.length === 0) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: '',\n            },\n          ],\n        };\n      }\n\n      // If only 1 tab, return plain text (backward compatibility)\n      if (tabs.length === 1) {\n        const tab = tabs[0];\n        let text = '';\n        if (docTitle) {\n          text += `Document Title: ${docTitle}\\n\\n`;\n        }\n        if (tab.documentTab?.body?.content) {\n          tab.documentTab.body.content.forEach((element) => {\n            text += this._readStructuralElement(element);\n          });\n        }\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: text,\n            },\n          ],\n        };\n      }\n\n      // If multiple tabs, return JSON\n      const tabsData = tabs.map((tab, index) => {\n        let tabText = '';\n        if (tab.documentTab?.body?.content) {\n          tab.documentTab.body.content.forEach((element) => {\n            tabText += this._readStructuralElement(element);\n          });\n        }\n        return {\n          tabId: tab.tabProperties?.tabId,\n          title: tab.tabProperties?.title,\n          content: tabText,\n          index: index,\n        };\n      });\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ title: docTitle, tabs: tabsData }, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[DocsService] Error during docs.getText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private _readStructuralElement(\n    element: docs_v1.Schema$StructuralElement,\n  ): string {\n    let text = '';\n    if (element.paragraph) {\n      element.paragraph.elements?.forEach((pElement) => {\n        if (pElement.textRun && pElement.textRun.content) {\n          text += pElement.textRun.content;\n        } else if (pElement.person?.personProperties) {\n          text += this._renderPersonChip(pElement.person.personProperties);\n        } else if (pElement.richLink?.richLinkProperties) {\n          text += this._renderRichLinkChip(\n            pElement.richLink.richLinkProperties,\n          );\n        } else if (pElement.dateElement?.dateElementProperties) {\n          text += this._renderDateChip(\n            pElement.dateElement.dateElementProperties,\n          );\n        }\n      });\n    } else if (element.table) {\n      element.table.tableRows?.forEach((row) => {\n        row.tableCells?.forEach((cell) => {\n          cell.content?.forEach((cellContent) => {\n            text += this._readStructuralElement(cellContent);\n          });\n        });\n      });\n    }\n    return text;\n  }\n\n  private _renderPersonChip(props: docs_v1.Schema$PersonProperties): string {\n    const { name, email } = props;\n    if (email) {\n      return `[${name || email}](mailto:${email})`;\n    }\n    return name || '';\n  }\n\n  private _renderRichLinkChip(\n    props: docs_v1.Schema$RichLinkProperties,\n  ): string {\n    const { title, uri } = props;\n    if (uri) {\n      return `[${title || uri}](${uri})`;\n    }\n    return title || '';\n  }\n\n  private _renderDateChip(props: docs_v1.Schema$DateElementProperties): string {\n    const { displayText, timestamp } = props;\n    return displayText || timestamp || '';\n  }\n\n  public replaceText = async ({\n    documentId,\n    findText,\n    replaceText,\n    tabId,\n  }: {\n    documentId: string;\n    findText: string;\n    replaceText: string;\n    tabId?: string;\n  }) => {\n    logToFile(\n      `[DocsService] Starting replaceText for document: ${documentId}, tabId: ${tabId}`,\n    );\n    try {\n      const id = extractDocId(documentId) || documentId;\n      const docs = await this.getDocsClient();\n\n      // Get the document to find where the text will be replaced\n      const docBefore = await docs.documents.get({\n        documentId: id,\n        fields: TABS_FIELD_MASK,\n        includeTabsContent: true,\n      });\n\n      const tabs = this._flattenTabs(docBefore.data.tabs || []);\n\n      const requests: docs_v1.Schema$Request[] = [];\n\n      if (tabId) {\n        const tab = tabs.find((t) => t.tabProperties?.tabId === tabId);\n        if (!tab) {\n          throw new Error(`Tab with ID ${tabId} not found.`);\n        }\n        const content = tab.documentTab?.body?.content;\n\n        const tabRequests = this._generateReplacementRequests(\n          content,\n          tabId,\n          findText,\n          replaceText,\n        );\n        requests.push(...tabRequests);\n      } else {\n        for (const tab of tabs) {\n          const currentTabId = tab.tabProperties?.tabId;\n          const content = tab.documentTab?.body?.content;\n\n          const tabRequests = this._generateReplacementRequests(\n            content,\n            currentTabId,\n            findText,\n            replaceText,\n          );\n          requests.push(...tabRequests);\n        }\n      }\n\n      if (requests.length > 0) {\n        await docs.documents.batchUpdate({\n          documentId: id,\n          requestBody: {\n            requests,\n          },\n        });\n      }\n\n      logToFile(`[DocsService] Finished replaceText for document: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: `Successfully replaced text in document ${id}`,\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[DocsService] Error during docs.replaceText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private _generateReplacementRequests(\n    content: docs_v1.Schema$StructuralElement[] | undefined,\n    tabId: string | undefined | null,\n    findText: string,\n    newText: string,\n  ): docs_v1.Schema$Request[] {\n    const requests: docs_v1.Schema$Request[] = [];\n    const documentText = this._getFullDocumentText(content);\n    const occurrences: number[] = [];\n    let searchIndex = 0;\n    while ((searchIndex = documentText.indexOf(findText, searchIndex)) !== -1) {\n      occurrences.push(searchIndex + 1);\n      searchIndex += findText.length;\n    }\n\n    const lengthDiff = newText.length - findText.length;\n    let cumulativeOffset = 0;\n\n    for (let i = 0; i < occurrences.length; i++) {\n      const occurrence = occurrences[i];\n      const adjustedPosition = occurrence + cumulativeOffset;\n\n      // Delete old text\n      requests.push({\n        deleteContentRange: {\n          range: {\n            tabId: tabId,\n            startIndex: adjustedPosition,\n            endIndex: adjustedPosition + findText.length,\n          },\n        },\n      });\n\n      // Insert new text\n      requests.push({\n        insertText: {\n          location: {\n            tabId: tabId,\n            index: adjustedPosition,\n          },\n          text: newText,\n        },\n      });\n\n      cumulativeOffset += lengthDiff;\n    }\n    return requests;\n  }\n\n  private _getFullDocumentText(\n    content: docs_v1.Schema$StructuralElement[] | undefined,\n  ): string {\n    let text = '';\n    if (content) {\n      content.forEach((element) => {\n        text += this._readStructuralElement(element);\n      });\n    }\n    return text;\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/services/DriveService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, drive_v3 } from 'googleapis';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\nimport { escapeQueryString } from '../utils/DriveQueryBuilder';\nimport { extractDocumentId } from '../utils/validation';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { PROJECT_ROOT } from '../utils/paths';\n\nconst MIN_DRIVE_ID_LENGTH = 25;\n\nconst URL_PATTERNS = [\n  { pattern: /\\/folders\\/([a-zA-Z0-9-_]+)/, type: 'folder' as const },\n  { pattern: /\\/file\\/d\\/([a-zA-Z0-9-_]+)/, type: 'file' as const },\n  { pattern: /\\/document\\/d\\/([a-zA-Z0-9-_]+)/, type: 'file' as const },\n  { pattern: /\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/, type: 'file' as const },\n  { pattern: /\\/presentation\\/d\\/([a-zA-Z0-9-_]+)/, type: 'file' as const },\n  { pattern: /\\/forms\\/d\\/([a-zA-Z0-9-_]+)/, type: 'file' as const },\n  { pattern: /[?&]id=([a-zA-Z0-9-_]+)/, type: 'unknown' as const },\n];\n\nexport class DriveService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getDriveClient(): Promise<drive_v3.Drive> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.drive({ version: 'v3', ...options });\n  }\n\n  private handleError(\n    context: string,\n    error: unknown,\n  ): {\n    isError: true;\n    content: { type: 'text'; text: string }[];\n  } {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n    logToFile(`Error during ${context}: ${errorMessage}`);\n    return {\n      isError: true,\n      content: [\n        {\n          type: 'text' as const,\n          text: JSON.stringify({ error: errorMessage }),\n        },\n      ],\n    };\n  }\n\n  public findFolder = async ({ folderName }: { folderName: string }) => {\n    logToFile(`Searching for folder with name: ${folderName}`);\n    try {\n      const drive = await this.getDriveClient();\n      const query = `mimeType='application/vnd.google-apps.folder' and name = '${escapeQueryString(folderName)}'`;\n      logToFile(`Executing Drive API query: ${query}`);\n      const res = await drive.files.list({\n        q: query,\n        fields: 'files(id, name)',\n        spaces: 'drive',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      const folders = res.data.files || [];\n      logToFile(`Found ${folders.length} folders.`);\n      logToFile(`API Response: ${JSON.stringify(folders, null, 2)}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(folders),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError('drive.findFolder', error);\n    }\n  };\n\n  public createFolder = async ({\n    name,\n    parentId,\n  }: {\n    name: string;\n    parentId?: string;\n  }) => {\n    logToFile(\n      `Creating folder with name: ${name} ${parentId ? `in parent: ${parentId}` : ''}`,\n    );\n    try {\n      const drive = await this.getDriveClient();\n      const fileMetadata: drive_v3.Schema$File = {\n        name: name,\n        mimeType: 'application/vnd.google-apps.folder',\n      };\n\n      if (parentId) {\n        fileMetadata.parents = [parentId];\n      }\n\n      const file = await drive.files.create({\n        requestBody: fileMetadata,\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      logToFile(`Created folder: ${file.data.name} (${file.data.id})`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              id: file.data.id,\n              name: file.data.name,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during drive.createFolder: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public search = async ({\n    query,\n    pageSize = 10,\n    pageToken,\n    corpus,\n    unreadOnly,\n    sharedWithMe,\n  }: {\n    query?: string;\n    pageSize?: number;\n    pageToken?: string;\n    corpus?: string;\n    unreadOnly?: boolean;\n    sharedWithMe?: boolean;\n  }) => {\n    const drive = await this.getDriveClient();\n    let q = query;\n    let isProcessed = false;\n\n    // Check if query is a Google Drive URL\n    if (\n      query &&\n      (query.includes('drive.google.com') || query.includes('docs.google.com'))\n    ) {\n      isProcessed = true;\n      logToFile(`Detected Google Drive URL in query: ${query}`);\n\n      let fileId: string | null = null;\n      let urlType: 'file' | 'folder' | 'unknown' = 'unknown';\n\n      for (const urlPattern of URL_PATTERNS) {\n        const match = query.match(urlPattern.pattern);\n        if (match) {\n          fileId = match[1];\n          urlType = urlPattern.type;\n          break;\n        }\n      }\n\n      if (fileId) {\n        let isFolder = urlType === 'folder';\n\n        if (urlType === 'unknown') {\n          try {\n            const file = await drive.files.get({\n              fileId,\n              fields: 'mimeType',\n              supportsAllDrives: true,\n            });\n            if (file.data.mimeType === 'application/vnd.google-apps.folder') {\n              isFolder = true;\n            }\n          } catch {\n            logToFile(\n              `Could not determine type of ID from URL, treating as file: ${fileId}`,\n            );\n          }\n        }\n\n        if (isFolder) {\n          q = `'${fileId}' in parents`;\n          logToFile(\n            `Extracted Folder ID from URL: ${fileId}, using query: ${q}`,\n          );\n        } else {\n          logToFile(`Extracted File ID from URL: ${fileId}, using files.get`);\n          try {\n            const res = await drive.files.get({\n              fileId: fileId,\n              fields:\n                'id, name, modifiedTime, viewedByMeTime, mimeType, parents',\n              supportsAllDrives: true,\n            });\n            return {\n              content: [\n                {\n                  type: 'text' as const,\n                  text: JSON.stringify({\n                    files: [res.data],\n                    nextPageToken: null,\n                  }),\n                },\n              ],\n            };\n          } catch (error) {\n            const errorMessage =\n              error instanceof Error ? error.message : String(error);\n            logToFile(`Error during drive.files.get: ${errorMessage}`);\n            return {\n              content: [\n                {\n                  type: 'text' as const,\n                  text: JSON.stringify({ error: errorMessage }),\n                },\n              ],\n            };\n          }\n        }\n      } else {\n        logToFile(`Could not extract file/folder ID from URL: ${query}`);\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                error:\n                  'Invalid Drive URL. Please provide a valid Google Drive URL or a search query.',\n                details:\n                  'Could not extract file or folder ID from the provided URL.',\n              }),\n            },\n          ],\n        };\n      }\n    }\n\n    if (query && !isProcessed) {\n      const titlePrefix = 'title:';\n      const trimmedQuery = query.trim();\n\n      if (trimmedQuery.startsWith(titlePrefix)) {\n        let searchTerm = trimmedQuery.substring(titlePrefix.length).trim();\n        if (\n          (searchTerm.startsWith(\"'\") && searchTerm.endsWith(\"'\")) ||\n          (searchTerm.startsWith('\"') && searchTerm.endsWith('\"'))\n        ) {\n          searchTerm = searchTerm.substring(1, searchTerm.length - 1);\n        }\n        q = `name contains '${escapeQueryString(searchTerm)}'`;\n      } else {\n        const driveIdPattern = new RegExp(\n          `^[a-zA-Z0-9-_]{${MIN_DRIVE_ID_LENGTH},}$`,\n        );\n        if (driveIdPattern.test(trimmedQuery) && !trimmedQuery.includes(' ')) {\n          q = `'${trimmedQuery}' in parents`;\n          logToFile(`Detected Drive ID: ${trimmedQuery}, listing contents`);\n        } else {\n          const looksLikeQuery = /( and | or | not | contains | in |=)/.test(\n            trimmedQuery,\n          );\n          if (!looksLikeQuery) {\n            const escapedQuery = escapeQueryString(trimmedQuery);\n            q = `fullText contains '${escapedQuery}'`;\n          }\n        }\n      }\n    }\n\n    if (sharedWithMe) {\n      logToFile('Searching for files shared with the user.');\n      if (q) {\n        q += ' and sharedWithMe';\n      } else {\n        q = 'sharedWithMe';\n      }\n    }\n\n    logToFile(`Executing Drive search with query: ${q}`);\n    if (corpus) {\n      logToFile(`Using corpus: ${corpus}`);\n    }\n    if (unreadOnly) {\n      logToFile('Filtering for unread files only.');\n    }\n\n    try {\n      const res = await drive.files.list({\n        q: q,\n        pageSize: pageSize,\n        pageToken: pageToken,\n        corpus: corpus as 'user' | 'domain' | undefined,\n        fields:\n          'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)',\n        supportsAllDrives: true,\n        includeItemsFromAllDrives: true,\n      });\n\n      let files = res.data.files || [];\n      const nextPageToken = res.data.nextPageToken;\n\n      if (unreadOnly) {\n        files = files.filter((file) => !file.viewedByMeTime);\n      }\n\n      logToFile(`Found ${files.length} files.`);\n      if (nextPageToken) {\n        logToFile(`Next page token: ${nextPageToken}`);\n      }\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              files: files,\n              nextPageToken: nextPageToken,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during drive.search: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public trashFile = async ({ fileId }: { fileId: string }) => {\n    logToFile(`Trashing Drive file: ${fileId}`);\n    try {\n      const drive = await this.getDriveClient();\n      const id = extractDocumentId(fileId);\n\n      const file = await drive.files.update({\n        fileId: id,\n        requestBody: { trashed: true },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      logToFile(`Successfully trashed file: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              id: file.data.id,\n              name: file.data.name,\n              trashed: true,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError('drive.trashFile', error);\n    }\n  };\n\n  public renameFile = async ({\n    fileId,\n    newName,\n  }: {\n    fileId: string;\n    newName: string;\n  }) => {\n    logToFile(`Renaming Drive file: ${fileId} to \"${newName}\"`);\n    try {\n      const drive = await this.getDriveClient();\n      const id = extractDocumentId(fileId);\n\n      const file = await drive.files.update({\n        fileId: id,\n        requestBody: { name: newName },\n        fields: 'id, name',\n        supportsAllDrives: true,\n      });\n\n      logToFile(`Successfully renamed file: ${id} to \"${file.data.name}\"`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              id: file.data.id,\n              name: file.data.name,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError('drive.renameFile', error);\n    }\n  };\n\n  public getComments = async ({ fileId }: { fileId: string }) => {\n    logToFile(`[DriveService] Starting getComments for file: ${fileId}`);\n    try {\n      const drive = await this.getDriveClient();\n      const id = extractDocumentId(fileId);\n      const res = await drive.comments.list({\n        fileId: id,\n        fields:\n          'comments(id, content, author(displayName, emailAddress), createdTime, resolved, quotedFileContent(value), replies(id, content, author(displayName, emailAddress), createdTime, action))',\n      });\n\n      const comments = res.data.comments || [];\n      logToFile(\n        `[DriveService] Found ${comments.length} comments for file: ${fileId}`,\n      );\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(comments, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError('drive.getComments', error);\n    }\n  };\n\n  public moveFile = async ({\n    fileId,\n    folderId,\n    folderName,\n  }: {\n    fileId: string;\n    folderId?: string;\n    folderName?: string;\n  }) => {\n    logToFile(\n      `Moving Drive file: ${fileId} to ${folderId ? `folder ID: ${folderId}` : `folder name: ${folderName}`}`,\n    );\n    try {\n      const drive = await this.getDriveClient();\n      const id = extractDocumentId(fileId);\n\n      let targetFolderId = folderId;\n\n      if (!targetFolderId && folderName) {\n        const findResult = await this.findFolder({ folderName });\n        const parsed = JSON.parse(findResult.content[0].text);\n\n        if (parsed.error) {\n          throw new Error(parsed.error);\n        }\n\n        const folders = parsed as { id: string; name: string }[];\n        if (folders.length === 0) {\n          throw new Error(`Folder not found: ${folderName}`);\n        }\n\n        if (folders.length > 1) {\n          logToFile(\n            `Warning: Found multiple folders with name \"${folderName}\". Using the first one found.`,\n          );\n        }\n\n        targetFolderId = folders[0].id;\n      }\n\n      if (!targetFolderId) {\n        throw new Error('Either folderId or folderName must be provided.');\n      }\n\n      const file = await drive.files.get({\n        fileId: id,\n        fields: 'parents',\n        supportsAllDrives: true,\n      });\n\n      const previousParents = file.data.parents?.join(',');\n\n      const updated = await drive.files.update({\n        fileId: id,\n        addParents: targetFolderId,\n        removeParents: previousParents,\n        fields: 'id, name, parents',\n        supportsAllDrives: true,\n      });\n\n      logToFile(`Successfully moved file ${id} to folder ${targetFolderId}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              id: updated.data.id,\n              name: updated.data.name,\n              parents: updated.data.parents,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError('drive.moveFile', error);\n    }\n  };\n\n  public downloadFile = async ({\n    fileId,\n    localPath,\n  }: {\n    fileId: string;\n    localPath: string;\n  }) => {\n    logToFile(`Downloading Drive file ${fileId} to ${localPath}`);\n    try {\n      const drive = await this.getDriveClient();\n      const id = extractDocumentId(fileId);\n\n      // 1. Check if it's a Google Doc (special handling required, export instead of download)\n      const metadata = await drive.files.get({\n        fileId: id,\n        fields: 'id, name, mimeType',\n        supportsAllDrives: true,\n      });\n      const mimeType = metadata.data.mimeType || '';\n\n      const googleWorkspaceFileMap: Record<\n        string,\n        { tool: string; idName: string; type: string }\n      > = {\n        'application/vnd.google-apps.document': {\n          tool: 'docs.getText',\n          idName: 'documentId',\n          type: 'Google Doc',\n        },\n        'application/vnd.google-apps.spreadsheet': {\n          tool: 'sheets.getText',\n          idName: 'spreadsheetId',\n          type: 'Google Sheet',\n        },\n        'application/vnd.google-apps.presentation': {\n          tool: 'slides.getText',\n          idName: 'presentationId',\n          type: 'Google Slide',\n        },\n      };\n\n      if (mimeType in googleWorkspaceFileMap) {\n        const fileInfo = googleWorkspaceFileMap[mimeType];\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: `This is a ${fileInfo.type}. Direct download is not supported. Please use the '${fileInfo.tool}' tool with ${fileInfo.idName}: ${id}`,\n            },\n          ],\n        };\n      }\n\n      if (mimeType.includes('vnd.google-apps.')) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: `This is a Google Workspace file type (${mimeType}). Direct media download is not supported. Please use specific tools (docs.getText, slides.getText, etc.) or export it if supported.`,\n            },\n          ],\n        };\n      }\n\n      // 2. Download media\n      const response = await drive.files.get(\n        {\n          fileId: id,\n          alt: 'media',\n          supportsAllDrives: true,\n        },\n        { responseType: 'arraybuffer' },\n      );\n\n      const buffer = Buffer.from(response.data as unknown as ArrayBuffer);\n\n      // 3. Save to localPath\n      const absolutePath = path.isAbsolute(localPath)\n        ? localPath\n        : path.resolve(PROJECT_ROOT, localPath);\n      const dir = path.dirname(absolutePath);\n\n      await fs.promises.mkdir(dir, { recursive: true });\n\n      await fs.promises.writeFile(absolutePath, buffer);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: `Successfully downloaded file ${metadata.data.name} to ${absolutePath}`,\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error during drive.downloadFile: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/GmailService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, gmail_v1 } from 'googleapis';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { MimeHelper } from '../utils/MimeHelper';\nimport {\n  GMAIL_SEARCH_MAX_RESULTS,\n  GMAIL_BATCH_MODIFY_MAX_IDS,\n  GMAIL_NO_LABEL_CHANGES_MESSAGE,\n} from '../utils/constants';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\nimport { emailArraySchema } from '../utils/validation';\n\n// Type definitions for email parameters\ntype SendEmailParams = {\n  to: string | string[];\n  subject: string;\n  body: string;\n  cc?: string | string[];\n  bcc?: string | string[];\n  isHtml?: boolean;\n};\n\ntype CreateDraftParams = SendEmailParams & {\n  threadId?: string;\n};\n\ninterface GmailAttachment {\n  filename: string | null | undefined;\n  mimeType: string | null | undefined;\n  attachmentId: string | null | undefined;\n  size: number | null | undefined;\n}\n\nexport class GmailService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getGmailClient(): Promise<gmail_v1.Gmail> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.gmail({ version: 'v1', ...options });\n  }\n\n  /**\n   * Helper method to handle errors consistently across all methods\n   */\n  private handleError(error: unknown, context: string) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n    logToFile(`Error during ${context}: ${errorMessage}`);\n    return {\n      content: [\n        {\n          type: 'text' as const,\n          text: JSON.stringify({ error: errorMessage }),\n        },\n      ],\n    };\n  }\n\n  public search = async ({\n    query,\n    maxResults = GMAIL_SEARCH_MAX_RESULTS,\n    pageToken,\n    labelIds,\n    includeSpamTrash = false,\n  }: {\n    query?: string;\n    maxResults?: number;\n    pageToken?: string;\n    labelIds?: string[];\n    includeSpamTrash?: boolean;\n  }) => {\n    try {\n      logToFile(`Gmail search - query: ${query}, maxResults: ${maxResults}`);\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.messages.list({\n        userId: 'me',\n        q: query,\n        maxResults,\n        pageToken,\n        labelIds,\n        includeSpamTrash,\n      });\n\n      const messages = response.data.messages || [];\n      const nextPageToken = response.data.nextPageToken;\n      const resultSizeEstimate = response.data.resultSizeEstimate;\n\n      logToFile(\n        `Found ${messages.length} messages, estimated total: ${resultSizeEstimate}`,\n      );\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                messages: messages.map((msg) => ({\n                  id: msg.id,\n                  threadId: msg.threadId,\n                })),\n                nextPageToken,\n                resultSizeEstimate,\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.search');\n    }\n  };\n\n  public get = async ({\n    messageId,\n    format = 'full',\n  }: {\n    messageId: string;\n    format?: 'minimal' | 'full' | 'raw' | 'metadata';\n  }) => {\n    try {\n      logToFile(`Getting message ${messageId} with format: ${format}`);\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.messages.get({\n        userId: 'me',\n        id: messageId,\n        format,\n      });\n\n      const message = response.data;\n\n      // Extract useful information based on format\n      if (format === 'metadata' || format === 'full') {\n        const headers = message.payload?.headers || [];\n        const getHeader = (name: string) =>\n          headers.find((h) => h.name === name)?.value;\n\n        const subject = getHeader('Subject');\n        const from = getHeader('From');\n        const to = getHeader('To');\n        const date = getHeader('Date');\n\n        // Extract body and attachments for full format\n        let body = '';\n        let attachments: GmailAttachment[] = [];\n        if (format === 'full' && message.payload) {\n          const result = this.extractAttachmentsAndBody(message.payload);\n          body = result.body;\n          attachments = result.attachments;\n        }\n\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify(\n                {\n                  id: message.id,\n                  threadId: message.threadId,\n                  labelIds: message.labelIds,\n                  snippet: message.snippet,\n                  subject,\n                  from,\n                  to,\n                  date,\n                  body: body || message.snippet,\n                  attachments: attachments,\n                },\n                null,\n                2,\n              ),\n            },\n          ],\n        };\n      }\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(message, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.get');\n    }\n  };\n\n  public downloadAttachment = async ({\n    messageId,\n    attachmentId,\n    localPath,\n  }: {\n    messageId: string;\n    attachmentId: string;\n    localPath: string;\n  }) => {\n    try {\n      logToFile(\n        `Downloading attachment ${attachmentId} from message ${messageId} to ${localPath}`,\n      );\n\n      if (!path.isAbsolute(localPath)) {\n        throw new Error('localPath must be an absolute path.');\n      }\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.messages.attachments.get({\n        userId: 'me',\n        messageId: messageId,\n        id: attachmentId,\n      });\n\n      const data = response.data.data;\n      if (!data) {\n        throw new Error('Attachment data is empty');\n      }\n\n      // Ensure directory exists\n      await fs.mkdir(path.dirname(localPath), { recursive: true });\n\n      // Write file\n      const buffer = Buffer.from(data, 'base64url');\n      await fs.writeFile(localPath, buffer);\n\n      logToFile(`Attachment downloaded successfully to ${localPath}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              message: `Attachment downloaded successfully to ${localPath}`,\n              path: localPath,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.downloadAttachment');\n    }\n  };\n\n  public modify = async ({\n    messageId,\n    addLabelIds = [],\n    removeLabelIds = [],\n  }: {\n    messageId: string;\n    addLabelIds?: string[];\n    removeLabelIds?: string[];\n  }) => {\n    try {\n      logToFile(\n        `Modifying message ${messageId} with addLabelIds: ${addLabelIds}, removeLabelIds: ${removeLabelIds}`,\n      );\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.messages.modify({\n        userId: 'me',\n        id: messageId,\n        requestBody: {\n          addLabelIds,\n          removeLabelIds,\n        },\n      });\n\n      const message = response.data;\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(message, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.modify');\n    }\n  };\n\n  public batchModify = async ({\n    messageIds,\n    addLabelIds = [],\n    removeLabelIds = [],\n  }: {\n    messageIds: string[];\n    addLabelIds?: string[];\n    removeLabelIds?: string[];\n  }) => {\n    try {\n      if (addLabelIds.length === 0 && removeLabelIds.length === 0) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                status: 'noop',\n                message: GMAIL_NO_LABEL_CHANGES_MESSAGE,\n              }),\n            },\n          ],\n        };\n      }\n\n      if (messageIds.length > GMAIL_BATCH_MODIFY_MAX_IDS) {\n        throw new Error(\n          `Too many message IDs. Maximum is ${GMAIL_BATCH_MODIFY_MAX_IDS}, got ${messageIds.length}.`,\n        );\n      }\n\n      logToFile(\n        `Batch modifying ${messageIds.length} messages with addLabelIds: ${addLabelIds}, removeLabelIds: ${removeLabelIds}`,\n      );\n\n      const gmail = await this.getGmailClient();\n      await gmail.users.messages.batchModify({\n        userId: 'me',\n        requestBody: {\n          ids: messageIds,\n          addLabelIds,\n          removeLabelIds,\n        },\n      });\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                modifiedCount: messageIds.length,\n                addLabelIds,\n                removeLabelIds,\n                status: 'success',\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.batchModify');\n    }\n  };\n\n  public modifyThread = async ({\n    threadId,\n    addLabelIds = [],\n    removeLabelIds = [],\n  }: {\n    threadId: string;\n    addLabelIds?: string[];\n    removeLabelIds?: string[];\n  }) => {\n    try {\n      if (addLabelIds.length === 0 && removeLabelIds.length === 0) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                status: 'noop',\n                message: GMAIL_NO_LABEL_CHANGES_MESSAGE,\n              }),\n            },\n          ],\n        };\n      }\n\n      logToFile(\n        `Modifying thread ${threadId} with addLabelIds: ${addLabelIds}, removeLabelIds: ${removeLabelIds}`,\n      );\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.threads.modify({\n        userId: 'me',\n        id: threadId,\n        requestBody: {\n          addLabelIds,\n          removeLabelIds,\n        },\n      });\n\n      const thread = response.data;\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(thread, null, 2),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.modifyThread');\n    }\n  };\n\n  public send = async ({\n    to,\n    subject,\n    body,\n    cc,\n    bcc,\n    isHtml = false,\n  }: SendEmailParams) => {\n    try {\n      // Validate email addresses\n      try {\n        emailArraySchema.parse(to);\n        if (cc) emailArraySchema.parse(cc);\n        if (bcc) emailArraySchema.parse(bcc);\n      } catch (error) {\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({\n                error: 'Invalid email address format',\n                details:\n                  error instanceof Error ? error.message : 'Validation failed',\n              }),\n            },\n          ],\n        };\n      }\n\n      logToFile(`Sending email to: ${to}, subject: ${subject}`);\n\n      // Create MIME message\n      const mimeMessage = MimeHelper.createMimeMessage({\n        to: Array.isArray(to) ? to.join(', ') : to,\n        subject,\n        body,\n        cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined,\n        bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined,\n        isHtml,\n      });\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.messages.send({\n        userId: 'me',\n        requestBody: {\n          raw: mimeMessage,\n        },\n      });\n\n      logToFile(`Email sent successfully: ${response.data.id}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                id: response.data.id,\n                threadId: response.data.threadId,\n                labelIds: response.data.labelIds,\n                status: 'sent',\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.send');\n    }\n  };\n\n  public createDraft = async ({\n    to,\n    subject,\n    body,\n    cc,\n    bcc,\n    isHtml = false,\n    threadId,\n  }: CreateDraftParams) => {\n    try {\n      logToFile(`Creating draft - to: ${to}, subject: ${subject}`);\n\n      const gmail = await this.getGmailClient();\n\n      // If threadId is provided, fetch the last message to get reply headers\n      let inReplyTo: string | undefined;\n      let references: string | undefined;\n      if (threadId) {\n        try {\n          const threadResponse = await gmail.users.threads.get({\n            userId: 'me',\n            id: threadId,\n            format: 'metadata',\n            metadataHeaders: ['Message-ID', 'References'],\n          });\n          const messages = threadResponse.data.messages || [];\n          if (messages.length > 0) {\n            const lastMessage = messages[messages.length - 1];\n            const headers = lastMessage.payload?.headers || [];\n            const messageIdHeader = headers.find(\n              (h) => h.name?.toLowerCase() === 'message-id',\n            );\n            const referencesHeader = headers.find(\n              (h) => h.name?.toLowerCase() === 'references',\n            );\n            if (messageIdHeader?.value) {\n              inReplyTo = messageIdHeader.value;\n              const previousReferences = referencesHeader?.value || '';\n              references = previousReferences\n                ? `${previousReferences} ${messageIdHeader.value}`\n                : messageIdHeader.value;\n            }\n          }\n        } catch (threadError) {\n          logToFile(\n            `Warning: Could not fetch thread ${threadId} for reply headers: ${threadError}`,\n          );\n        }\n      }\n\n      // Create MIME message\n      const mimeMessage = MimeHelper.createMimeMessage({\n        to: Array.isArray(to) ? to.join(', ') : to,\n        subject,\n        body,\n        cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined,\n        bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined,\n        isHtml,\n        inReplyTo,\n        references,\n      });\n\n      const response = await gmail.users.drafts.create({\n        userId: 'me',\n        requestBody: {\n          message: {\n            raw: mimeMessage,\n            ...(threadId && { threadId }),\n          },\n        },\n      });\n\n      logToFile(`Draft created successfully: ${response.data.id}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                id: response.data.id,\n                message: {\n                  id: response.data.message?.id,\n                  threadId: response.data.message?.threadId,\n                  labelIds: response.data.message?.labelIds,\n                },\n                status: 'draft_created',\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.createDraft');\n    }\n  };\n\n  public sendDraft = async ({ draftId }: { draftId: string }) => {\n    try {\n      logToFile(`Sending draft: ${draftId}`);\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.drafts.send({\n        userId: 'me',\n        requestBody: {\n          id: draftId,\n        },\n      });\n\n      logToFile(`Draft sent successfully: ${response.data.id}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                id: response.data.id,\n                threadId: response.data.threadId,\n                labelIds: response.data.labelIds,\n                status: 'sent',\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.sendDraft');\n    }\n  };\n\n  public listLabels = async () => {\n    try {\n      logToFile(`Listing Gmail labels`);\n\n      const gmail = await this.getGmailClient();\n      const response = await gmail.users.labels.list({\n        userId: 'me',\n      });\n\n      const labels = response.data.labels || [];\n\n      logToFile(`Found ${labels.length} labels`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                labels: labels.map((label) => ({\n                  id: label.id,\n                  name: label.name,\n                  type: label.type,\n                  messageListVisibility: label.messageListVisibility,\n                  labelListVisibility: label.labelListVisibility,\n                })),\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.listLabels');\n    }\n  };\n\n  public createLabel = async ({\n    name,\n    labelListVisibility = 'labelShow',\n    messageListVisibility = 'show',\n  }: {\n    name: string;\n    labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';\n    messageListVisibility?: 'show' | 'hide';\n  }) => {\n    try {\n      logToFile(`Creating Gmail label: ${name}`);\n\n      const gmail = await this.getGmailClient();\n\n      const response = await gmail.users.labels.create({\n        userId: 'me',\n        requestBody: {\n          name,\n          labelListVisibility,\n          messageListVisibility,\n        },\n      });\n\n      const label = response.data;\n\n      logToFile(`Created label: ${label.name} with id: ${label.id}`);\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(\n              {\n                id: label.id,\n                name: label.name,\n                type: label.type,\n                messageListVisibility: label.messageListVisibility,\n                labelListVisibility: label.labelListVisibility,\n                status: 'created',\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n      };\n    } catch (error) {\n      return this.handleError(error, 'gmail.createLabel');\n    }\n  };\n\n  private extractAttachmentsAndBody(\n    payload: gmail_v1.Schema$MessagePart,\n    result: { body: string; attachments: GmailAttachment[] } = {\n      body: '',\n      attachments: [],\n    },\n  ) {\n    if (!payload) return result;\n\n    // Handle body parts\n    if (payload.body?.data) {\n      // If it's the main body (and not an attachment)\n      if (!payload.filename || !payload.body.attachmentId) {\n        if (payload.mimeType?.startsWith('text/')) {\n          // Prioritize plain text over HTML for direct body extraction\n          if (!result.body || payload.mimeType === 'text/plain') {\n            result.body = Buffer.from(payload.body.data, 'base64').toString(\n              'utf-8',\n            );\n          }\n        }\n      }\n    }\n\n    // Handle attachments and recursive parts\n    if (payload.filename && payload.body?.attachmentId) {\n      result.attachments.push({\n        filename: payload.filename,\n        mimeType: payload.mimeType,\n        attachmentId: payload.body.attachmentId,\n        size: payload.body.size, // Size in bytes\n      });\n    }\n\n    if (payload.parts) {\n      for (const part of payload.parts) {\n        this.extractAttachmentsAndBody(part, result);\n      }\n    }\n    return result;\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/services/PeopleService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, people_v1 } from 'googleapis';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\n\nexport class PeopleService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getPeopleClient(): Promise<people_v1.People> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.people({ version: 'v1', ...options });\n  }\n\n  public getUserProfile = async ({\n    userId,\n    email,\n    name,\n  }: {\n    userId?: string;\n    email?: string;\n    name?: string;\n  }) => {\n    logToFile(\n      `[PeopleService] Starting getUserProfile with: userId=${userId}, email=${email}, name=${name}`,\n    );\n    try {\n      if (!userId && !email && !name) {\n        throw new Error('Either userId, email, or name must be provided.');\n      }\n      const people = await this.getPeopleClient();\n      if (userId) {\n        const resourceName = userId.startsWith('people/')\n          ? userId\n          : `people/${userId}`;\n        const res = await people.people.get({\n          resourceName,\n          personFields: 'names,emailAddresses',\n        });\n        logToFile(\n          `[PeopleService] Finished getUserProfile for user: ${userId}`,\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify({ results: [{ person: res.data }] }),\n            },\n          ],\n        };\n      } else if (email || name) {\n        const query = email || name;\n        const res = await people.people.searchDirectoryPeople({\n          query,\n          readMask: 'names,emailAddresses',\n          sources: [\n            'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT',\n            'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE',\n          ],\n        });\n        logToFile(\n          `[PeopleService] Finished getUserProfile search for: ${query}`,\n        );\n        return {\n          content: [\n            {\n              type: 'text' as const,\n              text: JSON.stringify(res.data),\n            },\n          ],\n        };\n      } else {\n        throw new Error('Either userId, email, or name must be provided.');\n      }\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[PeopleService] Error during people.getUserProfile: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getMe = async () => {\n    logToFile(`[PeopleService] Starting getMe`);\n    try {\n      const people = await this.getPeopleClient();\n      const res = await people.people.get({\n        resourceName: 'people/me',\n        personFields: 'names,emailAddresses',\n      });\n      logToFile(`[PeopleService] Finished getMe`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(res.data),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[PeopleService] Error during people.getMe: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  /**\n   * Gets a user's relations (e.g., manager, spouse, assistant).\n   * Defaults to the authenticated user if no userId is provided.\n   * Optionally filters by a specific relation type.\n   */\n  public getUserRelations = async ({\n    userId,\n    relationType,\n  }: {\n    userId?: string;\n    relationType?: string;\n  }) => {\n    const targetUser = userId\n      ? userId.startsWith('people/')\n        ? userId\n        : `people/${userId}`\n      : 'people/me';\n    logToFile(\n      `[PeopleService] Starting getUserRelations for ${targetUser} with relationType=${relationType}`,\n    );\n    try {\n      const people = await this.getPeopleClient();\n      const res = await people.people.get({\n        resourceName: targetUser,\n        personFields: 'relations',\n      });\n      logToFile(`[PeopleService] Finished getUserRelations API call`);\n\n      const relations = res.data?.relations || [];\n\n      const filteredRelations = relationType\n        ? relations.filter(\n            (relation) =>\n              relation.type?.toLowerCase() === relationType.toLowerCase(),\n          )\n        : relations;\n\n      if (relationType) {\n        logToFile(\n          `[PeopleService] Filtered to ${filteredRelations.length} relations of type: ${relationType}`,\n        );\n      } else {\n        logToFile(\n          `[PeopleService] Returning all ${filteredRelations.length} relations`,\n        );\n      }\n\n      const responseData = {\n        resourceName: targetUser,\n        ...(relationType && { relationType }),\n        relations: filteredRelations,\n      };\n\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(responseData),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[PeopleService] Error during people.getUserRelations: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/SheetsService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, sheets_v4 } from 'googleapis';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { extractDocId } from '../utils/IdUtils';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\n\nexport class SheetsService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getSheetsClient(): Promise<sheets_v4.Sheets> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.sheets({ version: 'v4', ...options });\n  }\n\n  public getText = async ({\n    spreadsheetId,\n    format = 'text',\n  }: {\n    spreadsheetId: string;\n    format?: 'text' | 'csv' | 'json';\n  }) => {\n    logToFile(\n      `[SheetsService] Starting getText for spreadsheet: ${spreadsheetId} with format: ${format}`,\n    );\n    try {\n      const id = extractDocId(spreadsheetId) || spreadsheetId;\n\n      const sheets = await this.getSheetsClient();\n      // Get spreadsheet metadata\n      const spreadsheet = await sheets.spreadsheets.get({\n        spreadsheetId: id,\n        includeGridData: false,\n      });\n\n      let content = '';\n      const jsonData: Record<string, any[][]> = {};\n\n      // Add spreadsheet title (except for JSON format)\n      if (spreadsheet.data.properties?.title && format !== 'json') {\n        content += `Spreadsheet Title: ${spreadsheet.data.properties.title}\\n\\n`;\n      }\n\n      // Get all sheet names\n      const sheetNames =\n        spreadsheet.data.sheets?.map((sheet) => sheet.properties?.title) || [];\n\n      // Get data from all sheets\n      for (const sheetName of sheetNames) {\n        if (!sheetName) continue;\n\n        try {\n          const response = await sheets.spreadsheets.values.get({\n            spreadsheetId: id,\n            range: `'${sheetName}'`,\n          });\n\n          const values = response.data.values || [];\n\n          if (format === 'json') {\n            // Collect data for JSON structure\n            jsonData[sheetName] = values;\n          } else {\n            // Add sheet name as context\n            content += `Sheet Name: ${sheetName}\\n`;\n\n            if (values.length === 0) {\n              content += '(Empty sheet)\\n';\n            } else {\n              // Process each row\n              values.forEach((row) => {\n                if (format === 'csv') {\n                  // Convert to CSV format\n                  const csvRow = row\n                    .map((cell) => {\n                      // Escape quotes and wrap in quotes if contains comma or quotes\n                      const cellStr = String(cell || '');\n                      if (\n                        cellStr.includes(',') ||\n                        cellStr.includes('\"') ||\n                        cellStr.includes('\\n')\n                      ) {\n                        return `\"${cellStr.replace(/\"/g, '\"\"')}\"`;\n                      }\n                      return cellStr;\n                    })\n                    .join(',');\n                  content += csvRow + '\\n';\n                } else {\n                  // Plain text format with pipe separators for readability\n                  content += row.map((cell) => cell || '').join(' | ') + '\\n';\n                }\n              });\n            }\n            content += '\\n';\n          }\n        } catch (sheetError) {\n          logToFile(\n            `[SheetsService] Error reading sheet ${sheetName}: ${sheetError}`,\n          );\n          if (format === 'json') {\n            // For JSON format, we'll skip sheets with errors\n            logToFile(\n              `[SheetsService] Skipping sheet ${sheetName} in JSON output due to error`,\n            );\n          } else {\n            content += `Sheet Name: ${sheetName}\\n(Error reading sheet)\\n\\n`;\n          }\n        }\n      }\n\n      if (format === 'json') {\n        // Generate clean JSON output from collected data\n        content = JSON.stringify(jsonData, null, 2);\n      }\n\n      logToFile(`[SheetsService] Finished getText for spreadsheet: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: content.trim(),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[SheetsService] Error during sheets.getText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getRange = async ({\n    spreadsheetId,\n    range,\n  }: {\n    spreadsheetId: string;\n    range: string;\n  }) => {\n    logToFile(\n      `[SheetsService] Starting getRange for spreadsheet: ${spreadsheetId}, range: ${range}`,\n    );\n    try {\n      const id = extractDocId(spreadsheetId) || spreadsheetId;\n\n      const sheets = await this.getSheetsClient();\n      const response = await sheets.spreadsheets.values.get({\n        spreadsheetId: id,\n        range: range,\n      });\n\n      const values = response.data.values || [];\n\n      logToFile(`[SheetsService] Finished getRange for spreadsheet: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({\n              range: response.data.range,\n              values: values,\n            }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[SheetsService] Error during sheets.getRange: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => {\n    logToFile(\n      `[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`,\n    );\n    try {\n      const id = extractDocId(spreadsheetId) || spreadsheetId;\n\n      const sheets = await this.getSheetsClient();\n      const spreadsheet = await sheets.spreadsheets.get({\n        spreadsheetId: id,\n        includeGridData: false,\n      });\n\n      const metadata = {\n        spreadsheetId: spreadsheet.data.spreadsheetId,\n        title: spreadsheet.data.properties?.title,\n        sheets: spreadsheet.data.sheets?.map((sheet) => ({\n          sheetId: sheet.properties?.sheetId,\n          title: sheet.properties?.title,\n          index: sheet.properties?.index,\n          rowCount: sheet.properties?.gridProperties?.rowCount,\n          columnCount: sheet.properties?.gridProperties?.columnCount,\n        })),\n        locale: spreadsheet.data.properties?.locale,\n        timeZone: spreadsheet.data.properties?.timeZone,\n      };\n\n      logToFile(`[SheetsService] Finished getMetadata for spreadsheet: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(metadata),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[SheetsService] Error during sheets.getMetadata: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/SlidesService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { google, slides_v1 } from 'googleapis';\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { request } from 'gaxios';\nimport { AuthManager } from '../auth/AuthManager';\nimport { logToFile } from '../utils/logger';\nimport { extractDocId } from '../utils/IdUtils';\nimport { gaxiosOptions } from '../utils/GaxiosConfig';\n\nexport class SlidesService {\n  constructor(private authManager: AuthManager) {}\n\n  private async getSlidesClient(): Promise<slides_v1.Slides> {\n    const auth = await this.authManager.getAuthenticatedClient();\n    const options = { ...gaxiosOptions, auth };\n    return google.slides({ version: 'v1', ...options });\n  }\n\n  public getText = async ({ presentationId }: { presentationId: string }) => {\n    logToFile(\n      `[SlidesService] Starting getText for presentation: ${presentationId}`,\n    );\n    try {\n      const id = extractDocId(presentationId) || presentationId;\n\n      const slides = await this.getSlidesClient();\n      // Get the presentation with all necessary fields\n      const presentation = await slides.presentations.get({\n        presentationId: id,\n        fields:\n          'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))',\n      });\n\n      let content = '';\n\n      // Add presentation title\n      if (presentation.data.title) {\n        content += `Presentation Title: ${presentation.data.title}\\n\\n`;\n      }\n\n      // Process each slide\n      if (presentation.data.slides) {\n        presentation.data.slides.forEach((slide, slideIndex) => {\n          content += `\\n--- Slide ${slideIndex + 1} ---\\n`;\n\n          if (slide.pageElements) {\n            slide.pageElements.forEach((element) => {\n              // Extract text from shapes\n              if (element.shape && element.shape.text) {\n                const shapeText = this.extractTextFromTextContent(\n                  element.shape.text,\n                );\n                if (shapeText) {\n                  content += shapeText + '\\n';\n                }\n              }\n\n              // Extract text from tables\n              if (element.table && element.table.tableRows) {\n                content += '\\n--- Table Data ---\\n';\n                element.table.tableRows.forEach((row) => {\n                  const rowText: string[] = [];\n                  if (row.tableCells) {\n                    row.tableCells.forEach((cell) => {\n                      const cellText = cell.text\n                        ? this.extractTextFromTextContent(cell.text)\n                        : '';\n                      rowText.push(cellText.trim());\n                    });\n                  }\n                  content += rowText.join(' | ') + '\\n';\n                });\n                content += '--- End Table Data ---\\n';\n              }\n            });\n          }\n          content += '\\n';\n        });\n      }\n\n      logToFile(`[SlidesService] Finished getText for presentation: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: content.trim(),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`[SlidesService] Error during slides.getText: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private extractTextFromTextContent(\n    textContent: slides_v1.Schema$TextContent,\n  ): string {\n    let text = '';\n    if (textContent.textElements) {\n      textContent.textElements.forEach((element) => {\n        if (element.textRun && element.textRun.content) {\n          text += element.textRun.content;\n        } else if (element.paragraphMarker) {\n          // Add newline for paragraph markers\n          text += '\\n';\n        }\n      });\n    }\n    return text;\n  }\n\n  public getMetadata = async ({\n    presentationId,\n  }: {\n    presentationId: string;\n  }) => {\n    logToFile(\n      `[SlidesService] Starting getMetadata for presentation: ${presentationId}`,\n    );\n    try {\n      const id = extractDocId(presentationId) || presentationId;\n\n      const slides = await this.getSlidesClient();\n      const presentation = await slides.presentations.get({\n        presentationId: id,\n        fields:\n          'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts',\n      });\n\n      const metadata = {\n        presentationId: presentation.data.presentationId,\n        title: presentation.data.title,\n        slideCount: presentation.data.slides?.length || 0,\n        slides:\n          presentation.data.slides?.map(({ objectId }) => ({ objectId })) ?? [],\n        pageSize: presentation.data.pageSize,\n        hasMasters: !!presentation.data.masters?.length,\n        hasLayouts: !!presentation.data.layouts?.length,\n        hasNotesMaster: !!presentation.data.notesMaster,\n      };\n\n      logToFile(`[SlidesService] Finished getMetadata for presentation: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(metadata),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[SlidesService] Error during slides.getMetadata: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  private async downloadToLocal(url: string, localPath: string) {\n    logToFile(`[SlidesService] Downloading from ${url} to ${localPath}`);\n    if (!path.isAbsolute(localPath)) {\n      throw new Error('localPath must be an absolute path.');\n    }\n\n    // Ensure directory exists\n    await fs.mkdir(path.dirname(localPath), { recursive: true });\n\n    const response = await request({\n      url,\n      responseType: 'arraybuffer',\n      ...gaxiosOptions,\n    });\n\n    await fs.writeFile(localPath, Buffer.from(response.data as ArrayBuffer));\n    logToFile(`[SlidesService] Downloaded successfully to ${localPath}`);\n    return localPath;\n  }\n\n  public getImages = async ({\n    presentationId,\n    localPath,\n  }: {\n    presentationId: string;\n    localPath: string;\n  }) => {\n    logToFile(\n      `[SlidesService] Starting getImages for presentation: ${presentationId} (localPath: ${localPath})`,\n    );\n    try {\n      const id = extractDocId(presentationId) || presentationId;\n      const slides = await this.getSlidesClient();\n      const presentation = await slides.presentations.get({\n        presentationId: id,\n        fields:\n          'slides(objectId,pageElements(objectId,title,description,image(contentUrl,sourceUrl)))',\n      });\n\n      const images = await Promise.all(\n        (presentation.data.slides ?? []).flatMap((slide, index) =>\n          (slide.pageElements ?? [])\n            .filter((element) => element.image)\n            .map(async (element) => {\n              const imageData: any = {\n                slideIndex: index + 1,\n                slideObjectId: slide.objectId,\n                elementObjectId: element.objectId,\n                title: element.title,\n                description: element.description,\n                contentUrl: element.image?.contentUrl,\n                sourceUrl: element.image?.sourceUrl,\n              };\n\n              if (imageData.contentUrl) {\n                const filename = `slide_${imageData.slideIndex}_${element.objectId}.png`;\n                const fullPath = path.join(localPath, filename);\n                try {\n                  await this.downloadToLocal(imageData.contentUrl, fullPath);\n                  imageData.localPath = fullPath;\n                } catch (downloadError) {\n                  logToFile(\n                    `[SlidesService] Failed to download image ${element.objectId}: ${downloadError}`,\n                  );\n                  imageData.downloadError = String(downloadError);\n                }\n              }\n\n              return imageData;\n            }),\n        ),\n      );\n\n      logToFile(`[SlidesService] Finished getImages for presentation: ${id}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ images }),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[SlidesService] Error during slides.getImages: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n\n  public getSlideThumbnail = async ({\n    presentationId,\n    slideObjectId,\n    localPath,\n  }: {\n    presentationId: string;\n    slideObjectId: string;\n    localPath: string;\n  }) => {\n    logToFile(\n      `[SlidesService] Starting getSlideThumbnail for presentation: ${presentationId}, slide: ${slideObjectId} (localPath: ${localPath})`,\n    );\n    try {\n      const id = extractDocId(presentationId) || presentationId;\n      const slides = await this.getSlidesClient();\n      const thumbnail = await slides.presentations.pages.getThumbnail({\n        presentationId: id,\n        pageObjectId: slideObjectId,\n      });\n\n      const result: any = { ...thumbnail.data };\n\n      if (result.contentUrl) {\n        try {\n          await this.downloadToLocal(result.contentUrl, localPath);\n          result.localPath = localPath;\n        } catch (downloadError) {\n          logToFile(\n            `[SlidesService] Failed to download thumbnail for slide ${slideObjectId}: ${downloadError}`,\n          );\n          result.downloadError = String(downloadError);\n        }\n      }\n\n      logToFile(\n        `[SlidesService] Finished getSlideThumbnail for slide: ${slideObjectId}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(result),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(\n        `[SlidesService] Error during slides.getSlideThumbnail: ${errorMessage}`,\n      );\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/services/TimeService.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from '../utils/logger';\n\nexport class TimeService {\n  constructor() {\n    logToFile('TimeService initialized.');\n  }\n\n  private async handleErrors<T>(\n    fn: () => Promise<T>,\n  ): Promise<{ content: [{ type: 'text'; text: string }] }> {\n    try {\n      const result = await fn();\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify(result),\n          },\n        ],\n      };\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      logToFile(`Error in TimeService: ${errorMessage}`);\n      return {\n        content: [\n          {\n            type: 'text' as const,\n            text: JSON.stringify({ error: errorMessage }),\n          },\n        ],\n      };\n    }\n  }\n\n  private getTimeContext() {\n    return {\n      now: new Date(),\n      timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    };\n  }\n\n  getCurrentDate = async () => {\n    logToFile('getCurrentDate called');\n    return this.handleErrors(async () => {\n      const { now, timeZone } = this.getTimeContext();\n      return {\n        utc: now.toISOString().slice(0, 10),\n        local: now.toLocaleDateString('en-CA', { timeZone }), // YYYY-MM-DD format\n        timeZone,\n      };\n    });\n  };\n\n  getCurrentTime = async () => {\n    logToFile('getCurrentTime called');\n    return this.handleErrors(async () => {\n      const { now, timeZone } = this.getTimeContext();\n      return {\n        utc: now.toISOString().slice(11, 19),\n        local: now.toLocaleTimeString('en-GB', { hour12: false, timeZone }), // HH:MM:SS format\n        timeZone,\n      };\n    });\n  };\n\n  getTimeZone = async () => {\n    logToFile('getTimeZone called');\n    return this.handleErrors(async () => {\n      return { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };\n    });\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/utils/DriveQueryBuilder.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Utility for escaping Google Drive API search query strings\n */\n\n/**\n * Escapes special characters in a query string for Drive API\n * @param str The string to escape\n * @returns The escaped string\n */\nexport function escapeQueryString(str: string): string {\n  return str.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\");\n}\n"
  },
  {
    "path": "workspace-server/src/utils/GaxiosConfig.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { GaxiosOptions } from 'gaxios';\nimport { logToFile } from './logger';\n\nexport const gaxiosOptions: GaxiosOptions = {\n  retryConfig: {\n    retry: 3,\n    noResponseRetries: 3,\n    retryDelay: 1000,\n    httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT'],\n    statusCodesToRetry: [\n      [429, 429],\n      [500, 599],\n    ],\n    onRetryAttempt: (err) => {\n      const config = err.config as GaxiosOptions;\n      logToFile(\n        `Retrying request to ${config.url}, attempt #${config.retryConfig?.currentRetryAttempt}`,\n      );\n      logToFile(`Error: ${err.message}`);\n    },\n  },\n  timeout: 30000,\n};\n\n// Extended timeout for media upload operations\nexport const mediaUploadOptions: GaxiosOptions = {\n  ...gaxiosOptions,\n  timeout: 60000, // 60 seconds for media uploads\n};\n"
  },
  {
    "path": "workspace-server/src/utils/IdUtils.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from './logger';\n\nconst DOC_ID_REGEX = /\\/d\\/([a-zA-Z0-9-_]+)/;\n\n/**\n * Extracts a Google Doc/Sheet/etc. ID from a Google Workspace URL.\n *\n * @param url The URL to parse.\n * @returns The extracted document ID, or undefined if no ID could be found.\n */\nexport function extractDocId(url: string): string | undefined {\n  logToFile(`[IdUtils] Attempting to extract doc ID from URL: ${url}`);\n  if (!url || typeof url !== 'string') {\n    logToFile(`[IdUtils] Invalid input: URL is null or not a string.`);\n    return undefined;\n  }\n  const match = url.match(DOC_ID_REGEX);\n  if (match && match[1]) {\n    const docId = match[1];\n    logToFile(`[IdUtils] Successfully extracted doc ID: ${docId}`);\n    return docId;\n  }\n  logToFile(`[IdUtils] Could not extract doc ID from URL.`);\n  return undefined;\n}\n"
  },
  {
    "path": "workspace-server/src/utils/MimeHelper.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Helper class for creating RFC 2822 compliant MIME messages for Gmail API\n */\nexport class MimeHelper {\n  /**\n   * Creates a base64url-encoded MIME message for Gmail API\n   */\n  public static createMimeMessage({\n    to,\n    subject,\n    body,\n    from,\n    cc,\n    bcc,\n    replyTo,\n    inReplyTo,\n    references,\n    isHtml = false,\n  }: {\n    to: string;\n    subject: string;\n    body: string;\n    from?: string;\n    cc?: string;\n    bcc?: string;\n    replyTo?: string;\n    inReplyTo?: string;\n    references?: string;\n    isHtml?: boolean;\n  }): string {\n    // Encode subject for UTF-8 support\n    const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`;\n\n    // Build message headers\n    const messageParts: string[] = [];\n\n    // Add From header if provided, otherwise Gmail will use the authenticated user\n    if (from) {\n      messageParts.push(`From: ${from}`);\n    }\n\n    messageParts.push(`To: ${to}`);\n\n    if (cc) {\n      messageParts.push(`Cc: ${cc}`);\n    }\n\n    if (bcc) {\n      messageParts.push(`Bcc: ${bcc}`);\n    }\n\n    if (replyTo) {\n      messageParts.push(`Reply-To: ${replyTo}`);\n    }\n\n    if (inReplyTo) {\n      messageParts.push(`In-Reply-To: ${inReplyTo}`);\n    }\n\n    if (references) {\n      messageParts.push(`References: ${references}`);\n    }\n\n    messageParts.push(`Subject: ${utf8Subject}`);\n\n    // Add content type based on whether it's HTML or plain text\n    if (isHtml) {\n      messageParts.push('Content-Type: text/html; charset=utf-8');\n    } else {\n      messageParts.push('Content-Type: text/plain; charset=utf-8');\n    }\n\n    messageParts.push(''); // Empty line between headers and body\n    messageParts.push(body);\n\n    // Join all parts with CRLF as per RFC 2822\n    const message = messageParts.join('\\r\\n');\n\n    // Encode to base64url format required by Gmail API\n    const encodedMessage = Buffer.from(message)\n      .toString('base64')\n      .replace(/\\+/g, '-')\n      .replace(/\\//g, '_')\n      .replace(/=+$/, '');\n\n    return encodedMessage;\n  }\n\n  /**\n   * Creates a MIME message with attachments\n   */\n  public static createMimeMessageWithAttachments({\n    to,\n    subject,\n    body,\n    from,\n    cc,\n    bcc,\n    attachments,\n    isHtml = false,\n  }: {\n    to: string;\n    subject: string;\n    body: string;\n    from?: string;\n    cc?: string;\n    bcc?: string;\n    attachments?: Array<{\n      filename: string;\n      content: Buffer | string;\n      contentType?: string;\n    }>;\n    isHtml?: boolean;\n  }): string {\n    const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(7)}`;\n    const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`;\n\n    const messageParts: string[] = [];\n\n    // Headers\n    if (from) {\n      messageParts.push(`From: ${from}`);\n    }\n    messageParts.push(`To: ${to}`);\n    if (cc) {\n      messageParts.push(`Cc: ${cc}`);\n    }\n    if (bcc) {\n      messageParts.push(`Bcc: ${bcc}`);\n    }\n    messageParts.push(`Subject: ${utf8Subject}`);\n    messageParts.push('MIME-Version: 1.0');\n\n    if (!attachments || attachments.length === 0) {\n      // Simple message without attachments\n      return this.createMimeMessage({\n        to,\n        subject,\n        body,\n        from,\n        cc,\n        bcc,\n        isHtml,\n      });\n    }\n\n    // Multipart message with attachments\n    messageParts.push(`Content-Type: multipart/mixed; boundary=\"${boundary}\"`);\n    messageParts.push('');\n\n    // Body part\n    messageParts.push(`--${boundary}`);\n    if (isHtml) {\n      messageParts.push('Content-Type: text/html; charset=utf-8');\n    } else {\n      messageParts.push('Content-Type: text/plain; charset=utf-8');\n    }\n    messageParts.push('');\n    messageParts.push(body);\n\n    // Attachments\n    for (const attachment of attachments) {\n      messageParts.push(`--${boundary}`);\n      messageParts.push(\n        `Content-Type: ${attachment.contentType || 'application/octet-stream'}`,\n      );\n      messageParts.push('Content-Transfer-Encoding: base64');\n      messageParts.push(\n        `Content-Disposition: attachment; filename=\"${attachment.filename}\"`,\n      );\n      messageParts.push('');\n\n      const content =\n        typeof attachment.content === 'string'\n          ? attachment.content\n          : attachment.content.toString('base64');\n\n      // Add content in chunks of 76 characters as per MIME spec\n      const chunks = content.match(/.{1,76}/g) || [];\n      messageParts.push(...chunks);\n    }\n\n    // End boundary\n    messageParts.push(`--${boundary}--`);\n\n    const message = messageParts.join('\\r\\n');\n\n    // Encode to base64url\n    return Buffer.from(message)\n      .toString('base64')\n      .replace(/\\+/g, '-')\n      .replace(/\\//g, '_')\n      .replace(/=+$/, '');\n  }\n\n  /**\n   * Decodes a base64url-encoded string (inverse of encoding)\n   */\n  public static decodeBase64Url(encoded: string): string {\n    // Add back padding if needed\n    let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');\n    while (base64.length % 4) {\n      base64 += '=';\n    }\n    return Buffer.from(base64, 'base64').toString('utf-8');\n  }\n}\n"
  },
  {
    "path": "workspace-server/src/utils/config.ts",
    "content": "/**\n * @license\n * Copyright 2026 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { logToFile } from './logger';\n\nexport interface WorkspaceConfig {\n  clientId: string;\n  cloudFunctionUrl: string;\n}\n\nconst DEFAULT_CONFIG: WorkspaceConfig = {\n  clientId:\n    '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com',\n  cloudFunctionUrl: 'https://google-workspace-extension.geminicli.com',\n};\n\n/**\n * Loads the configuration. Currently uses defaults, but can be extended\n * to read from environment variables or a configuration file.\n */\nexport function loadConfig(): WorkspaceConfig {\n  const config: WorkspaceConfig = {\n    clientId: process.env['WORKSPACE_CLIENT_ID'] || DEFAULT_CONFIG.clientId,\n    cloudFunctionUrl:\n      process.env['WORKSPACE_CLOUD_FUNCTION_URL'] ||\n      DEFAULT_CONFIG.cloudFunctionUrl,\n  };\n\n  const maskedClientId =\n    config.clientId.length > 2\n      ? `...${config.clientId.slice(-2)}`\n      : config.clientId;\n  logToFile(\n    `Loaded config: clientId=${maskedClientId}, cloudFunctionUrl=${config.cloudFunctionUrl}`,\n  );\n  return config;\n}\n"
  },
  {
    "path": "workspace-server/src/utils/constants.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport const GMAIL_SEARCH_MAX_RESULTS = 100;\nexport const GMAIL_BATCH_MODIFY_MAX_IDS = 1000;\nexport const GMAIL_NO_LABEL_CHANGES_MESSAGE =\n  'No labels to add or remove were provided. No action taken.';\n"
  },
  {
    "path": "workspace-server/src/utils/logger.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport * as fs from 'node:fs/promises';\nimport * as path from 'node:path';\nimport { PROJECT_ROOT } from './paths';\n\nconst logFilePath = path.join(PROJECT_ROOT, 'logs', 'server.log');\n\nasync function ensureLogDirectoryExists() {\n  try {\n    await fs.mkdir(path.dirname(logFilePath), { recursive: true });\n  } catch (error) {\n    // If we can't create the log directory, log to console as a fallback.\n    console.error('Could not create log directory:', error);\n  }\n}\n\n// Ensure the directory exists when the module is loaded.\nensureLogDirectoryExists();\n\nlet isLoggingEnabled = false;\n\nexport function setLoggingEnabled(enabled: boolean) {\n  isLoggingEnabled = enabled;\n}\n\nexport function logToFile(message: string) {\n  if (!isLoggingEnabled) {\n    return;\n  }\n  const timestamp = new Date().toISOString();\n  const logMessage = `${timestamp} - ${message}\\n`;\n\n  fs.appendFile(logFilePath, logMessage).catch((err) => {\n    // Fallback to console if file logging fails\n    console.error('Failed to write to log file:', err);\n  });\n}\n"
  },
  {
    "path": "workspace-server/src/utils/open-wrapper.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * This module acts as a drop-in replacement for the 'open' package.\n * It intercepts browser launch requests and either:\n * 1. Opens the browser securely using our secure-browser-launcher\n * 2. Prints the URL to console if browser launch should be skipped or fails\n */\n\nimport {\n  openBrowserSecurely,\n  shouldLaunchBrowser,\n} from './secure-browser-launcher';\n\n// Create a mock child process object that matches what open returns\nconst createMockChildProcess = () => ({\n  unref: () => {},\n  ref: () => {},\n  pid: 123,\n  stdout: null,\n  stderr: null,\n  stdin: null,\n  channel: null,\n  connected: false,\n  exitCode: 0,\n  killed: false,\n  signalCode: null,\n  spawnargs: [],\n  spawnfile: '',\n});\n\nconst openWrapper = async (url: string): Promise<any> => {\n  // Check if we should launch the browser\n  if (!shouldLaunchBrowser()) {\n    console.log(\n      `Browser launch not supported. Please open this URL in your browser: ${url}`,\n    );\n    return createMockChildProcess();\n  }\n\n  // Try to open the browser securely\n  try {\n    await openBrowserSecurely(url);\n    return createMockChildProcess();\n  } catch {\n    console.log(\n      `Failed to open browser. Please open this URL in your browser: ${url}`,\n    );\n    return createMockChildProcess();\n  }\n};\n\n// Use standard ES Module export and let the compiler generate the CommonJS correct output.\nexport default openWrapper;\n"
  },
  {
    "path": "workspace-server/src/utils/paths.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport path from 'node:path';\nimport * as fs from 'node:fs';\n\nfunction findProjectRoot(): string {\n  let dir = __dirname;\n  while (dir !== path.dirname(dir)) {\n    if (fs.existsSync(path.join(dir, 'gemini-extension.json'))) {\n      return dir;\n    }\n    dir = path.dirname(dir);\n  }\n  throw new Error(\n    `Could not find project root containing gemini-extension.json. Traversed up from ${__dirname}.`,\n  );\n}\n\n// Construct an absolute path to the project root.\nexport const PROJECT_ROOT = findProjectRoot();\nexport const ENCRYPTED_TOKEN_PATH = path.join(\n  PROJECT_ROOT,\n  'gemini-cli-workspace-token.json',\n);\nexport const ENCRYPTION_MASTER_KEY_PATH = path.join(\n  PROJECT_ROOT,\n  '.gemini-cli-workspace-master-key',\n);\n"
  },
  {
    "path": "workspace-server/src/utils/secure-browser-launcher.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { execFile, ExecFileOptions } from 'node:child_process';\nimport { platform } from 'node:os';\nimport { URL } from 'node:url';\n\nfunction withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {\n  let timeoutId: NodeJS.Timeout;\n  const timeout = new Promise<T>((_, reject) => {\n    timeoutId = setTimeout(() => reject(new Error('Timeout')), ms);\n  });\n  return Promise.race([promise, timeout]).finally(() =>\n    clearTimeout(timeoutId),\n  );\n}\n\n/**\n * Validates that a URL is safe to open in a browser.\n * Only allows HTTP and HTTPS URLs to prevent command injection.\n *\n * @param url The URL to validate\n * @throws Error if the URL is invalid or uses an unsafe protocol\n */\nfunction validateUrl(url: string): void {\n  let parsedUrl: URL;\n\n  try {\n    parsedUrl = new URL(url);\n  } catch {\n    throw new Error('Invalid URL');\n  }\n\n  // Only allow HTTP and HTTPS protocols\n  if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {\n    throw new Error(\n      `Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,\n    );\n  }\n\n  // Additional validation: ensure no newlines or control characters\n  if (/[\\r\\n\\x00-\\x1F]/.test(url)) {\n    throw new Error('URL contains invalid characters');\n  }\n}\n\n/**\n * Opens a URL in the default browser using platform-specific commands.\n * This implementation avoids shell injection vulnerabilities by:\n * 1. Validating the URL to ensure it's HTTP/HTTPS only\n * 2. Using execFile instead of exec to avoid shell interpretation\n * 3. Passing the URL as an argument rather than constructing a command string\n *\n * @param url The URL to open\n * @param execFileFn The function to execute a command. Defaults to node's execFile.\n * @throws Error if the URL is invalid or if opening the browser fails\n */\nexport async function openBrowserSecurely(\n  url: string,\n  execFileFn: typeof execFile = execFile,\n): Promise<void> {\n  // Validate the URL first\n  validateUrl(url);\n\n  const platformName = platform();\n  let command: string;\n  let args: string[];\n\n  switch (platformName) {\n    case 'darwin':\n      // macOS\n      command = 'open';\n      args = [url];\n      break;\n\n    case 'win32':\n      // Windows - use PowerShell with Start-Process\n      // This avoids the cmd.exe shell which is vulnerable to injection\n      command = 'powershell.exe';\n      args = [\n        '-NoProfile',\n        '-NonInteractive',\n        '-WindowStyle',\n        'Hidden',\n        '-Command',\n        `Start-Process '${url.replace(/'/g, \"''\")}'`,\n      ];\n      break;\n\n    case 'linux':\n    case 'freebsd':\n    case 'openbsd':\n      // Linux and BSD variants\n      // Try xdg-open first, fall back to other options\n      command = 'xdg-open';\n      args = [url];\n      break;\n\n    default:\n      throw new Error(`Unsupported platform: ${platformName}`);\n  }\n\n  const options: Record<string, unknown> = {\n    // Don't inherit parent's environment to avoid potential issues\n    env: {\n      ...process.env,\n      // Ensure we're not in a shell that might interpret special characters\n      SHELL: undefined,\n    },\n    // Detach the browser process so it doesn't block\n    detached: true,\n    stdio: 'ignore',\n  };\n\n  const tryCommand = (cmd: string, cmdArgs: string[]): Promise<void> => {\n    return new Promise((resolve, reject) => {\n      const child = execFileFn(\n        cmd,\n        cmdArgs,\n        options as ExecFileOptions,\n        (error) => {\n          if (error) {\n            // This callback handles errors after the process has run,\n            // but for our case, the 'error' event is more important for spawn failures.\n            reject(error);\n          }\n        },\n      );\n\n      // The 'error' event is critical. It fires if the command cannot be found or spawned.\n      child.on('error', (error) => {\n        reject(error);\n      });\n\n      // If the process spawns successfully, 'xdg-open' and similar commands\n      // exit almost immediately. We don't need to wait for the browser to close.\n      // We can consider the job done if the process exits with code 0.\n      child.on('exit', (code) => {\n        if (code === 0) {\n          resolve();\n        } else {\n          reject(new Error(`Process exited with code ${code}`));\n        }\n      });\n    });\n  };\n\n  try {\n    await withTimeout(tryCommand(command, args), 5000);\n  } catch (error) {\n    // For Linux, try fallback commands if xdg-open fails\n    if (\n      (platformName === 'linux' ||\n        platformName === 'freebsd' ||\n        platformName === 'openbsd') &&\n      command === 'xdg-open'\n    ) {\n      const fallbackCommands = [\n        'gnome-open',\n        'kde-open',\n        'firefox',\n        'chromium',\n        'google-chrome',\n      ];\n\n      for (const fallbackCommand of fallbackCommands) {\n        try {\n          await withTimeout(tryCommand(fallbackCommand, [url]), 5000);\n          return; // Success!\n        } catch {\n          // Try next command\n          continue;\n        }\n      }\n    }\n\n    // Re-throw the error if all attempts failed\n    throw new Error(\n      `Failed to open browser: ${\n        error instanceof Error ? error.message : 'Unknown error'\n      }`,\n    );\n  }\n}\n\n/**\n * Checks if the current environment should attempt to launch a browser.\n * This is the same logic as in browser.ts for consistency.\n *\n * @returns True if the tool should attempt to launch a browser\n */\nexport function shouldLaunchBrowser(): boolean {\n  // A list of browser names that indicate we should not attempt to open a\n  // web browser for the user.\n  const browserBlocklist = ['www-browser'];\n  const browserEnv = process.env.BROWSER;\n  if (browserEnv && browserBlocklist.includes(browserEnv)) {\n    return false;\n  }\n\n  // Common environment variables used in CI/CD or other non-interactive shells.\n  if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') {\n    return false;\n  }\n\n  // The presence of SSH_CONNECTION indicates a remote session.\n  // We should not attempt to launch a browser unless a display is explicitly available\n  // (checked below for Linux).\n  const isSSH = !!process.env.SSH_CONNECTION;\n\n  // On Linux, the presence of a display server is a strong indicator of a GUI.\n  if (platform() === 'linux') {\n    // These are environment variables that can indicate a running compositor on Linux.\n    const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];\n    const hasDisplay = displayVariables.some((v) => !!process.env[v]);\n    if (!hasDisplay) {\n      return false;\n    }\n  }\n\n  // If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.\n  // The Linux case is handled above (it's allowed if DISPLAY is set).\n  if (isSSH && platform() !== 'linux') {\n    return false;\n  }\n\n  // For non-Linux OSes, we generally assume a GUI is available\n  // unless other signals (like SSH) suggest otherwise.\n  return true;\n}\n"
  },
  {
    "path": "workspace-server/src/utils/tool-normalization.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n\n// Utility for normalizing tool names\n/**\n * Wraps the McpServer.registerTool method to normalize tool names.\n * If useDotNames is true, dots in tool names are preserved.\n * If useDotNames is false (default), dots are replaced with underscores.\n *\n * @param server The McpServer instance to modify.\n * @param useDotNames Whether to preserve dot notation in tool names.\n */\nexport function applyToolNameNormalization(\n  server: McpServer,\n  useDotNames: boolean,\n): void {\n  const separator = useDotNames ? '.' : '_';\n  const originalRegisterTool = server.registerTool.bind(server);\n\n  // We use `any` for the override to match the varying signatures of registerTool\n  // while maintaining the runtime behavior we need.\n  // The original signature is roughly:\n  // registerTool(name: string, toolDef: Tool, handler: ToolHandler): void\n  (server as any).registerTool = (name: string, ...rest: unknown[]) => {\n    const normalizedName = name.replace(/\\./g, separator);\n    // Cast originalRegisterTool to accept spread arguments\n    return (originalRegisterTool as (...args: unknown[]) => unknown)(\n      normalizedName,\n      ...rest,\n    );\n  };\n}\n"
  },
  {
    "path": "workspace-server/src/utils/validation.ts",
    "content": "/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport { z } from 'zod';\n\n/**\n * Email validation schema\n * Validates email format according to RFC 5322\n */\nexport const emailSchema = z.string().email('Invalid email format');\n\n/**\n * Validates multiple email addresses (for CC/BCC fields)\n */\nexport const emailArraySchema = z.union([emailSchema, z.array(emailSchema)]);\n\n/**\n * ISO 8601 datetime validation schema\n * Accepts formats like:\n * - 2024-01-15T10:30:00Z\n * - 2024-01-15T10:30:00-05:00\n * - 2024-01-15T10:30:00.000Z\n */\nexport const iso8601DateTimeSchema = z.string().refine(\n  (val) => {\n    const iso8601Regex =\n      /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{3})?(Z|[+-]\\d{2}:\\d{2})$/;\n    if (!iso8601Regex.test(val)) return false;\n\n    // Additional check: ensure it's a valid date\n    const date = new Date(val);\n    return !isNaN(date.getTime());\n  },\n  {\n    message:\n      'Invalid ISO 8601 datetime format. Expected format: YYYY-MM-DDTHH:mm:ss[.sss][Z|±HH:mm]',\n  },\n);\n\n/**\n * Google Drive document/file ID validation\n * Google IDs are typically alphanumeric strings with hyphens and underscores\n */\nexport const googleDocumentIdSchema = z\n  .string()\n  .regex(\n    /^[a-zA-Z0-9_-]+$/,\n    'Invalid document ID format. Document IDs should only contain letters, numbers, hyphens, and underscores',\n  );\n\n/**\n * Google Drive URL validation\n * Accepts various Google Workspace URLs and extracts the document ID\n */\nexport const googleWorkspaceUrlSchema = z\n  .string()\n  .regex(\n    /^https:\\/\\/(docs|drive|sheets|slides)\\.google\\.com\\/.+\\/d\\/([a-zA-Z0-9_-]+)/,\n    'Invalid Google Workspace URL format',\n  );\n\n/**\n * Folder name validation\n * Prevents problematic characters in folder names\n */\nexport const folderNameSchema = z\n  .string()\n  .min(1, 'Folder name cannot be empty')\n  .max(255, 'Folder name too long (max 255 characters)')\n  .refine(\n    (val) => !/[<>:\"/\\\\|?*\\x00-\\x1F]/.test(val),\n    'Folder name contains invalid characters',\n  );\n\n/**\n * Calendar ID validation\n * Can be 'primary' or an email address\n */\nexport const calendarIdSchema = z.union([z.literal('primary'), emailSchema]);\n\n/**\n * Search query sanitization\n * Escapes potentially dangerous characters from search queries\n * Preserves quotes for exact phrase searching\n */\nexport const searchQuerySchema = z.string().transform((val) => {\n  // Escape backslashes first, then escape quotes\n  // This preserves the ability to search for exact phrases\n  return val\n    .replace(/\\\\/g, '\\\\\\\\') // Escape backslashes\n    .replace(/'/g, \"\\\\'\") // Escape single quotes\n    .replace(/\"/g, '\\\\\"'); // Escape double quotes\n});\n\n/**\n * Page size validation for pagination\n */\nexport const pageSizeSchema = z\n  .number()\n  .int('Page size must be an integer')\n  .min(1, 'Page size must be at least 1')\n  .max(100, 'Page size cannot exceed 100');\n\n/**\n * Helper function to create a validator from a Zod schema\n */\nfunction createValidator<T>(\n  schema: z.ZodSchema<T>,\n  fallbackErrorMessage: string,\n) {\n  return (value: unknown): { success: boolean; error?: string } => {\n    try {\n      schema.parse(value);\n      return { success: true };\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        return { success: false, error: error.issues[0].message };\n      }\n      return { success: false, error: fallbackErrorMessage };\n    }\n  };\n}\n\n/**\n * Helper function to validate email\n */\nexport const validateEmail = createValidator(\n  emailSchema,\n  'Invalid email format',\n);\n\n/**\n * Helper function to validate ISO 8601 datetime\n */\nexport const validateDateTime = createValidator(\n  iso8601DateTimeSchema,\n  'Invalid datetime format',\n);\n\n/**\n * Helper function to validate Google document ID\n */\nexport const validateDocumentId = createValidator(\n  googleDocumentIdSchema,\n  'Invalid document ID',\n);\n\n/**\n * Helper function to extract document ID from URL or return the ID if already valid\n */\nexport function extractDocumentId(urlOrId: string): string {\n  // First check if it's already a valid ID\n  if (googleDocumentIdSchema.safeParse(urlOrId).success) {\n    return urlOrId;\n  }\n\n  // Try to extract from URL\n  const urlMatch = urlOrId.match(/\\/d\\/([a-zA-Z0-9_-]+)/);\n  if (urlMatch && urlMatch[1]) {\n    return urlMatch[1];\n  }\n\n  throw new Error('Invalid document ID or URL');\n}\n\n/**\n * Validation error class for consistent error handling\n */\nexport class ValidationError extends Error {\n  constructor(\n    message: string,\n    public field: string,\n    public value: unknown,\n  ) {\n    super(message);\n    this.name = 'ValidationError';\n  }\n}\n"
  },
  {
    "path": "workspace-server/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"./src\",\n    \"outDir\": \"./dist\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"dist\", \"node_modules\", \"src/**/*.test.ts\", \"src/**/*.spec.ts\"]\n}\n"
  },
  {
    "path": "workspace-server/tsconfig.test.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"strict\": false,\n    \"noImplicitAny\": false\n  },\n  \"include\": [\"src/**/*.test.ts\", \"src/**/*.spec.ts\"]\n}\n"
  }
]