Repository: google-gemini/gemini-cli Branch: main Commit: 8615315711a8 Files: 2279 Total size: 18.6 MB Directory structure: gitextract_3lf9y2ue/ ├── .allstar/ │ └── branch_protection.yaml ├── .editorconfig ├── .gcp/ │ ├── Dockerfile.gemini-code-builder │ └── release-docker.yml ├── .gemini/ │ ├── config.yaml │ └── settings.json ├── .geminiignore ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── feature_request.yml │ │ └── website_issue.yml │ ├── actions/ │ │ ├── calculate-vars/ │ │ │ └── action.yml │ │ ├── create-pull-request/ │ │ │ └── action.yml │ │ ├── npm-auth-token/ │ │ │ └── action.yml │ │ ├── post-coverage-comment/ │ │ │ └── action.yml │ │ ├── publish-release/ │ │ │ └── action.yml │ │ ├── push-docker/ │ │ │ └── action.yml │ │ ├── push-sandbox/ │ │ │ └── action.yml │ │ ├── run-tests/ │ │ │ └── action.yml │ │ ├── setup-npmrc/ │ │ │ └── action.yml │ │ ├── tag-npm-release/ │ │ │ └── action.yml │ │ └── verify-release/ │ │ └── action.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ ├── scripts/ │ │ ├── backfill-need-triage.cjs │ │ ├── backfill-pr-notification.cjs │ │ ├── pr-triage.sh │ │ └── sync-maintainer-labels.cjs │ └── workflows/ │ ├── chained_e2e.yml │ ├── ci.yml │ ├── community-report.yml │ ├── deflake.yml │ ├── docs-page-action.yml │ ├── docs-rebuild.yml │ ├── eval.yml │ ├── evals-nightly.yml │ ├── gemini-automated-issue-dedup.yml │ ├── gemini-automated-issue-triage.yml │ ├── gemini-scheduled-issue-dedup.yml │ ├── gemini-scheduled-issue-triage.yml │ ├── gemini-scheduled-pr-triage.yml │ ├── gemini-scheduled-stale-issue-closer.yml │ ├── gemini-scheduled-stale-pr-closer.yml │ ├── gemini-self-assign-issue.yml │ ├── issue-opened-labeler.yml │ ├── label-backlog-child-issues.yml │ ├── label-workstream-rollup.yml │ ├── links.yml │ ├── no-response.yml │ ├── pr-contribution-guidelines-notifier.yml │ ├── pr-rate-limiter.yaml │ ├── release-change-tags.yml │ ├── release-manual.yml │ ├── release-nightly.yml │ ├── release-notes.yml │ ├── release-patch-0-from-comment.yml │ ├── release-patch-1-create-pr.yml │ ├── release-patch-2-trigger.yml │ ├── release-patch-3-release.yml │ ├── release-promote.yml │ ├── release-rollback.yml │ ├── release-sandbox.yml │ ├── smoke-test.yml │ ├── stale.yml │ ├── test-build-binary.yml │ ├── trigger_e2e.yml │ ├── unassign-inactive-assignees.yml │ └── verify-release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .lycheeignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── .yamllint.yml ├── CONTRIBUTING.md ├── Dockerfile ├── GEMINI.md ├── LICENSE ├── Makefile ├── README.md ├── ROADMAP.md ├── SECURITY.md ├── docs/ │ ├── admin/ │ │ └── enterprise-controls.md │ ├── changelogs/ │ │ ├── index.md │ │ ├── latest.md │ │ └── preview.md │ ├── cli/ │ │ ├── checkpointing.md │ │ ├── cli-reference.md │ │ ├── creating-skills.md │ │ ├── custom-commands.md │ │ ├── enterprise.md │ │ ├── gemini-ignore.md │ │ ├── gemini-md.md │ │ ├── generation-settings.md │ │ ├── headless.md │ │ ├── model-routing.md │ │ ├── model-steering.md │ │ ├── model.md │ │ ├── notifications.md │ │ ├── plan-mode.md │ │ ├── rewind.md │ │ ├── sandbox.md │ │ ├── session-management.md │ │ ├── settings.md │ │ ├── skills.md │ │ ├── system-prompt.md │ │ ├── telemetry.md │ │ ├── themes.md │ │ ├── token-caching.md │ │ ├── trusted-folders.md │ │ └── tutorials/ │ │ ├── automation.md │ │ ├── file-management.md │ │ ├── mcp-setup.md │ │ ├── memory-management.md │ │ ├── plan-mode-steering.md │ │ ├── session-management.md │ │ ├── shell-commands.md │ │ ├── skills-getting-started.md │ │ ├── task-planning.md │ │ └── web-tools.md │ ├── core/ │ │ ├── index.md │ │ ├── local-model-routing.md │ │ ├── remote-agents.md │ │ └── subagents.md │ ├── examples/ │ │ └── proxy-script.md │ ├── extensions/ │ │ ├── best-practices.md │ │ ├── index.md │ │ ├── reference.md │ │ ├── releasing.md │ │ └── writing-extensions.md │ ├── get-started/ │ │ ├── authentication.md │ │ ├── examples.md │ │ ├── gemini-3.md │ │ ├── index.md │ │ └── installation.md │ ├── hooks/ │ │ ├── best-practices.md │ │ ├── index.md │ │ ├── reference.md │ │ └── writing-hooks.md │ ├── ide-integration/ │ │ ├── ide-companion-spec.md │ │ └── index.md │ ├── index.md │ ├── integration-tests.md │ ├── issue-and-pr-automation.md │ ├── local-development.md │ ├── mermaid/ │ │ ├── context.mmd │ │ └── render-path.mmd │ ├── npm.md │ ├── redirects.json │ ├── reference/ │ │ ├── commands.md │ │ ├── configuration.md │ │ ├── keyboard-shortcuts.md │ │ ├── memport.md │ │ ├── policy-engine.md │ │ └── tools.md │ ├── release-confidence.md │ ├── releases.md │ ├── resources/ │ │ ├── faq.md │ │ ├── quota-and-pricing.md │ │ ├── tos-privacy.md │ │ ├── troubleshooting.md │ │ └── uninstall.md │ ├── sidebar.json │ └── tools/ │ ├── activate-skill.md │ ├── ask-user.md │ ├── file-system.md │ ├── internal-docs.md │ ├── mcp-server.md │ ├── memory.md │ ├── planning.md │ ├── shell.md │ ├── todos.md │ ├── web-fetch.md │ └── web-search.md ├── esbuild.config.js ├── eslint.config.js ├── evals/ │ ├── README.md │ ├── answer-vs-act.eval.ts │ ├── app-test-helper.ts │ ├── ask_user.eval.ts │ ├── automated-tool-use.eval.ts │ ├── concurrency-safety.eval.ts │ ├── edit-locations-eval.eval.ts │ ├── frugalReads.eval.ts │ ├── frugalSearch.eval.ts │ ├── generalist_agent.eval.ts │ ├── generalist_delegation.eval.ts │ ├── gitRepo.eval.ts │ ├── grep_search_functionality.eval.ts │ ├── hierarchical_memory.eval.ts │ ├── interactive-hang.eval.ts │ ├── model_steering.eval.ts │ ├── plan_mode.eval.ts │ ├── save_memory.eval.ts │ ├── shell-efficiency.eval.ts │ ├── subagents.eval.ts │ ├── test-helper.ts │ ├── tool_output_masking.eval.ts │ ├── tracker.eval.ts │ ├── validation_fidelity.eval.ts │ ├── validation_fidelity_pre_existing_errors.eval.ts │ └── vitest.config.ts ├── integration-tests/ │ ├── acp-env-auth.test.ts │ ├── acp-telemetry.test.ts │ ├── api-resilience.responses │ ├── api-resilience.test.ts │ ├── browser-agent.cleanup.responses │ ├── browser-agent.confirmation.responses │ ├── browser-agent.interaction.responses │ ├── browser-agent.navigate-snapshot.responses │ ├── browser-agent.screenshot.responses │ ├── browser-agent.sequential.responses │ ├── browser-agent.test.ts │ ├── browser-policy.responses │ ├── browser-policy.test.ts │ ├── checkpointing.test.ts │ ├── clipboard-linux.test.ts │ ├── concurrency-limit.responses │ ├── concurrency-limit.test.ts │ ├── context-compress-interactive.compress-empty.responses │ ├── context-compress-interactive.compress-failure.responses │ ├── context-compress-interactive.compress.responses │ ├── context-compress-interactive.test.ts │ ├── ctrl-c-exit.test.ts │ ├── deprecation-warnings.test.ts │ ├── extensions-install.test.ts │ ├── extensions-reload.test.ts │ ├── file-system-interactive.test.ts │ ├── file-system.test.ts │ ├── flicker-detector.max-height.responses │ ├── flicker.test.ts │ ├── globalSetup.ts │ ├── google_web_search.test.ts │ ├── hooks-agent-flow-multistep.responses │ ├── hooks-agent-flow.responses │ ├── hooks-agent-flow.test.ts │ ├── hooks-system.after-agent.responses │ ├── hooks-system.after-model.responses │ ├── hooks-system.after-tool-context.responses │ ├── hooks-system.allow-tool.responses │ ├── hooks-system.before-agent.responses │ ├── hooks-system.before-model.responses │ ├── hooks-system.before-tool-selection.responses │ ├── hooks-system.before-tool-stop.responses │ ├── hooks-system.block-tool.responses │ ├── hooks-system.compress-auto.responses │ ├── hooks-system.disabled-via-command.responses │ ├── hooks-system.disabled-via-settings.responses │ ├── hooks-system.error-handling.responses │ ├── hooks-system.input-modification.responses │ ├── hooks-system.input-validation.responses │ ├── hooks-system.multiple-events.responses │ ├── hooks-system.notification.responses │ ├── hooks-system.sequential-execution.responses │ ├── hooks-system.session-clear.responses │ ├── hooks-system.session-startup.responses │ ├── hooks-system.tail-tool-call.responses │ ├── hooks-system.telemetry.responses │ ├── hooks-system.test.ts │ ├── json-output.error.responses │ ├── json-output.france.responses │ ├── json-output.session-id.responses │ ├── json-output.test.ts │ ├── list_directory.test.ts │ ├── mcp_server_cyclic_schema.test.ts │ ├── mixed-input-crash.test.ts │ ├── parallel-tools.responses │ ├── parallel-tools.test.ts │ ├── plan-mode.test.ts │ ├── policy-headless-readonly.responses │ ├── policy-headless-shell-allowed.responses │ ├── policy-headless-shell-denied.responses │ ├── policy-headless.test.ts │ ├── read_many_files.test.ts │ ├── replace.test.ts │ ├── resume_repro.responses │ ├── resume_repro.test.ts │ ├── ripgrep-real.test.ts │ ├── run_shell_command.test.ts │ ├── simple-mcp-server.test.ts │ ├── skill-creator-scripts.test.ts │ ├── skill-creator-vulnerabilities.test.ts │ ├── stdin-context.test.ts │ ├── stdout-stderr-output-error.responses │ ├── stdout-stderr-output.responses │ ├── stdout-stderr-output.test.ts │ ├── symlink-install.test.ts │ ├── telemetry.test.ts │ ├── test-helper.ts │ ├── test-mcp-server.ts │ ├── tsconfig.json │ ├── user-policy.responses │ ├── user-policy.test.ts │ ├── utf-bom-encoding.test.ts │ ├── vitest.config.ts │ └── write_file.test.ts ├── package.json ├── packages/ │ ├── a2a-server/ │ │ ├── GEMINI.md │ │ ├── README.md │ │ ├── development-extension-rfc.md │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agent/ │ │ │ │ ├── executor.test.ts │ │ │ │ ├── executor.ts │ │ │ │ ├── task-event-driven.test.ts │ │ │ │ ├── task.test.ts │ │ │ │ └── task.ts │ │ │ ├── commands/ │ │ │ │ ├── command-registry.test.ts │ │ │ │ ├── command-registry.ts │ │ │ │ ├── extensions.test.ts │ │ │ │ ├── extensions.ts │ │ │ │ ├── init.test.ts │ │ │ │ ├── init.ts │ │ │ │ ├── memory.test.ts │ │ │ │ ├── memory.ts │ │ │ │ ├── restore.test.ts │ │ │ │ ├── restore.ts │ │ │ │ └── types.ts │ │ │ ├── config/ │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── extension.ts │ │ │ │ ├── settings.test.ts │ │ │ │ └── settings.ts │ │ │ ├── http/ │ │ │ │ ├── app.test.ts │ │ │ │ ├── app.ts │ │ │ │ ├── endpoints.test.ts │ │ │ │ ├── requestStorage.ts │ │ │ │ └── server.ts │ │ │ ├── index.ts │ │ │ ├── persistence/ │ │ │ │ ├── gcs.test.ts │ │ │ │ └── gcs.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── executor_utils.ts │ │ │ ├── logger.ts │ │ │ └── testing_utils.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── cli/ │ │ ├── GEMINI.md │ │ ├── examples/ │ │ │ ├── ask-user-dialog-demo.tsx │ │ │ └── scrollable-list-demo.tsx │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __snapshots__/ │ │ │ │ └── nonInteractiveCli.test.ts.snap │ │ │ ├── acp/ │ │ │ │ ├── acpClient.test.ts │ │ │ │ ├── acpClient.ts │ │ │ │ ├── acpErrors.test.ts │ │ │ │ ├── acpErrors.ts │ │ │ │ ├── acpResume.test.ts │ │ │ │ ├── commandHandler.test.ts │ │ │ │ ├── commandHandler.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── commandRegistry.ts │ │ │ │ │ ├── extensions.ts │ │ │ │ │ ├── init.ts │ │ │ │ │ ├── memory.ts │ │ │ │ │ ├── restore.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── fileSystemService.test.ts │ │ │ │ └── fileSystemService.ts │ │ │ ├── commands/ │ │ │ │ ├── extensions/ │ │ │ │ │ ├── configure.test.ts │ │ │ │ │ ├── configure.ts │ │ │ │ │ ├── disable.test.ts │ │ │ │ │ ├── disable.ts │ │ │ │ │ ├── enable.test.ts │ │ │ │ │ ├── enable.ts │ │ │ │ │ ├── examples/ │ │ │ │ │ │ ├── custom-commands/ │ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ │ ├── commands/ │ │ │ │ │ │ │ │ └── fs/ │ │ │ │ │ │ │ │ └── grep-code.toml │ │ │ │ │ │ │ └── gemini-extension.json │ │ │ │ │ │ ├── exclude-tools/ │ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ │ └── gemini-extension.json │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ │ ├── gemini-extension.json │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ └── hooks.json │ │ │ │ │ │ │ └── scripts/ │ │ │ │ │ │ │ └── on-start.js │ │ │ │ │ │ ├── mcp-server/ │ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ │ ├── example.js │ │ │ │ │ │ │ ├── gemini-extension.json │ │ │ │ │ │ │ └── package.json │ │ │ │ │ │ ├── policies/ │ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ │ ├── gemini-extension.json │ │ │ │ │ │ │ └── policies/ │ │ │ │ │ │ │ └── policies.toml │ │ │ │ │ │ ├── skills/ │ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ │ ├── gemini-extension.json │ │ │ │ │ │ │ └── skills/ │ │ │ │ │ │ │ └── greeter/ │ │ │ │ │ │ │ └── SKILL.md │ │ │ │ │ │ └── themes-example/ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ └── gemini-extension.json │ │ │ │ │ ├── install.test.ts │ │ │ │ │ ├── install.ts │ │ │ │ │ ├── link.test.ts │ │ │ │ │ ├── link.ts │ │ │ │ │ ├── list.test.ts │ │ │ │ │ ├── list.ts │ │ │ │ │ ├── new.test.ts │ │ │ │ │ ├── new.ts │ │ │ │ │ ├── uninstall.test.ts │ │ │ │ │ ├── uninstall.ts │ │ │ │ │ ├── update.test.ts │ │ │ │ │ ├── update.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── validate.test.ts │ │ │ │ │ └── validate.ts │ │ │ │ ├── extensions.test.tsx │ │ │ │ ├── extensions.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── migrate.test.ts │ │ │ │ │ └── migrate.ts │ │ │ │ ├── hooks.tsx │ │ │ │ ├── mcp/ │ │ │ │ │ ├── add.test.ts │ │ │ │ │ ├── add.ts │ │ │ │ │ ├── enableDisable.ts │ │ │ │ │ ├── list.test.ts │ │ │ │ │ ├── list.ts │ │ │ │ │ ├── remove.test.ts │ │ │ │ │ └── remove.ts │ │ │ │ ├── mcp.test.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── skills/ │ │ │ │ │ ├── disable.test.ts │ │ │ │ │ ├── disable.ts │ │ │ │ │ ├── enable.test.ts │ │ │ │ │ ├── enable.ts │ │ │ │ │ ├── install.test.ts │ │ │ │ │ ├── install.ts │ │ │ │ │ ├── link.test.ts │ │ │ │ │ ├── link.ts │ │ │ │ │ ├── list.test.ts │ │ │ │ │ ├── list.ts │ │ │ │ │ ├── uninstall.test.ts │ │ │ │ │ └── uninstall.ts │ │ │ │ ├── skills.test.tsx │ │ │ │ ├── skills.tsx │ │ │ │ ├── utils.test.ts │ │ │ │ └── utils.ts │ │ │ ├── config/ │ │ │ │ ├── auth.test.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── config.integration.test.ts │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── extension-manager-agents.test.ts │ │ │ │ ├── extension-manager-hydration.test.ts │ │ │ │ ├── extension-manager-permissions.test.ts │ │ │ │ ├── extension-manager-scope.test.ts │ │ │ │ ├── extension-manager-skills.test.ts │ │ │ │ ├── extension-manager-themes.spec.ts │ │ │ │ ├── extension-manager.test.ts │ │ │ │ ├── extension-manager.ts │ │ │ │ ├── extension.test.ts │ │ │ │ ├── extension.ts │ │ │ │ ├── extensionRegistryClient.test.ts │ │ │ │ ├── extensionRegistryClient.ts │ │ │ │ ├── extensions/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── consent.test.ts.snap │ │ │ │ │ ├── consent.test.ts │ │ │ │ │ ├── consent.ts │ │ │ │ │ ├── extensionEnablement.test.ts │ │ │ │ │ ├── extensionEnablement.ts │ │ │ │ │ ├── extensionSettings.test.ts │ │ │ │ │ ├── extensionSettings.ts │ │ │ │ │ ├── extensionUpdates.test.ts │ │ │ │ │ ├── github.test.ts │ │ │ │ │ ├── github.ts │ │ │ │ │ ├── github_fetch.test.ts │ │ │ │ │ ├── github_fetch.ts │ │ │ │ │ ├── storage.test.ts │ │ │ │ │ ├── storage.ts │ │ │ │ │ ├── update.test.ts │ │ │ │ │ ├── update.ts │ │ │ │ │ ├── variableSchema.ts │ │ │ │ │ ├── variables.test.ts │ │ │ │ │ └── variables.ts │ │ │ │ ├── footerItems.test.ts │ │ │ │ ├── footerItems.ts │ │ │ │ ├── mcp/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mcpServerEnablement.test.ts │ │ │ │ │ └── mcpServerEnablement.ts │ │ │ │ ├── policy-engine.integration.test.ts │ │ │ │ ├── policy.test.ts │ │ │ │ ├── policy.ts │ │ │ │ ├── sandboxConfig.test.ts │ │ │ │ ├── sandboxConfig.ts │ │ │ │ ├── settingPaths.test.ts │ │ │ │ ├── settingPaths.ts │ │ │ │ ├── settings-validation.test.ts │ │ │ │ ├── settings-validation.ts │ │ │ │ ├── settings.test.ts │ │ │ │ ├── settings.ts │ │ │ │ ├── settingsSchema.test.ts │ │ │ │ ├── settingsSchema.ts │ │ │ │ ├── settings_repro.test.ts │ │ │ │ ├── settings_validation_warning.test.ts │ │ │ │ ├── trustedFolders.test.ts │ │ │ │ ├── trustedFolders.ts │ │ │ │ └── workspace-policy-cli.test.ts │ │ │ ├── core/ │ │ │ │ ├── auth.test.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── initializer.test.ts │ │ │ │ ├── initializer.ts │ │ │ │ ├── theme.test.ts │ │ │ │ └── theme.ts │ │ │ ├── deferred.test.ts │ │ │ ├── deferred.ts │ │ │ ├── gemini.test.tsx │ │ │ ├── gemini.tsx │ │ │ ├── gemini_cleanup.test.tsx │ │ │ ├── integration-tests/ │ │ │ │ └── modelSteering.test.tsx │ │ │ ├── interactiveCli.tsx │ │ │ ├── nonInteractiveCli.test.ts │ │ │ ├── nonInteractiveCli.ts │ │ │ ├── nonInteractiveCliCommands.ts │ │ │ ├── patches/ │ │ │ │ └── is-in-ci.ts │ │ │ ├── services/ │ │ │ │ ├── BuiltinCommandLoader.test.ts │ │ │ │ ├── BuiltinCommandLoader.ts │ │ │ │ ├── CommandService.test.ts │ │ │ │ ├── CommandService.ts │ │ │ │ ├── FileCommandLoader.test.ts │ │ │ │ ├── FileCommandLoader.ts │ │ │ │ ├── McpPromptLoader.test.ts │ │ │ │ ├── McpPromptLoader.ts │ │ │ │ ├── SkillCommandLoader.test.ts │ │ │ │ ├── SkillCommandLoader.ts │ │ │ │ ├── SlashCommandConflictHandler.test.ts │ │ │ │ ├── SlashCommandConflictHandler.ts │ │ │ │ ├── SlashCommandResolver.test.ts │ │ │ │ ├── SlashCommandResolver.ts │ │ │ │ ├── prompt-processors/ │ │ │ │ │ ├── argumentProcessor.test.ts │ │ │ │ │ ├── argumentProcessor.ts │ │ │ │ │ ├── atFileProcessor.test.ts │ │ │ │ │ ├── atFileProcessor.ts │ │ │ │ │ ├── injectionParser.test.ts │ │ │ │ │ ├── injectionParser.ts │ │ │ │ │ ├── shellProcessor.test.ts │ │ │ │ │ ├── shellProcessor.ts │ │ │ │ │ └── types.ts │ │ │ │ └── types.ts │ │ │ ├── test-utils/ │ │ │ │ ├── AppRig.test.tsx │ │ │ │ ├── AppRig.tsx │ │ │ │ ├── MockShellExecutionService.ts │ │ │ │ ├── async.ts │ │ │ │ ├── createExtension.ts │ │ │ │ ├── customMatchers.ts │ │ │ │ ├── fixtures/ │ │ │ │ │ ├── simple.responses │ │ │ │ │ └── steering.responses │ │ │ │ ├── mockCommandContext.test.ts │ │ │ │ ├── mockCommandContext.ts │ │ │ │ ├── mockConfig.ts │ │ │ │ ├── mockDebugLogger.ts │ │ │ │ ├── persistentStateFake.ts │ │ │ │ ├── render.test.tsx │ │ │ │ ├── render.tsx │ │ │ │ ├── settings.ts │ │ │ │ └── svg.ts │ │ │ ├── ui/ │ │ │ │ ├── App.test.tsx │ │ │ │ ├── App.tsx │ │ │ │ ├── AppContainer.test.tsx │ │ │ │ ├── AppContainer.tsx │ │ │ │ ├── IdeIntegrationNudge.test.tsx │ │ │ │ ├── IdeIntegrationNudge.tsx │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── App.test.tsx.snap │ │ │ │ ├── auth/ │ │ │ │ │ ├── ApiAuthDialog.test.tsx │ │ │ │ │ ├── ApiAuthDialog.tsx │ │ │ │ │ ├── AuthDialog.test.tsx │ │ │ │ │ ├── AuthDialog.tsx │ │ │ │ │ ├── AuthInProgress.test.tsx │ │ │ │ │ ├── AuthInProgress.tsx │ │ │ │ │ ├── BannedAccountDialog.test.tsx │ │ │ │ │ ├── BannedAccountDialog.tsx │ │ │ │ │ ├── LoginWithGoogleRestartDialog.test.tsx │ │ │ │ │ ├── LoginWithGoogleRestartDialog.tsx │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ ├── ApiAuthDialog.test.tsx.snap │ │ │ │ │ │ ├── AuthDialog.test.tsx.snap │ │ │ │ │ │ ├── BannedAccountDialog.test.tsx.snap │ │ │ │ │ │ └── LoginWithGoogleRestartDialog.test.tsx.snap │ │ │ │ │ ├── useAuth.test.tsx │ │ │ │ │ └── useAuth.ts │ │ │ │ ├── colors.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── aboutCommand.test.ts │ │ │ │ │ ├── aboutCommand.ts │ │ │ │ │ ├── agentsCommand.test.ts │ │ │ │ │ ├── agentsCommand.ts │ │ │ │ │ ├── authCommand.test.ts │ │ │ │ │ ├── authCommand.ts │ │ │ │ │ ├── bugCommand.test.ts │ │ │ │ │ ├── bugCommand.ts │ │ │ │ │ ├── chatCommand.test.ts │ │ │ │ │ ├── chatCommand.ts │ │ │ │ │ ├── clearCommand.test.ts │ │ │ │ │ ├── clearCommand.ts │ │ │ │ │ ├── commandsCommand.test.ts │ │ │ │ │ ├── commandsCommand.ts │ │ │ │ │ ├── compressCommand.test.ts │ │ │ │ │ ├── compressCommand.ts │ │ │ │ │ ├── copyCommand.test.ts │ │ │ │ │ ├── copyCommand.ts │ │ │ │ │ ├── corgiCommand.test.ts │ │ │ │ │ ├── corgiCommand.ts │ │ │ │ │ ├── directoryCommand.test.tsx │ │ │ │ │ ├── directoryCommand.tsx │ │ │ │ │ ├── docsCommand.test.ts │ │ │ │ │ ├── docsCommand.ts │ │ │ │ │ ├── editorCommand.test.ts │ │ │ │ │ ├── editorCommand.ts │ │ │ │ │ ├── extensionsCommand.test.ts │ │ │ │ │ ├── extensionsCommand.ts │ │ │ │ │ ├── footerCommand.tsx │ │ │ │ │ ├── helpCommand.test.ts │ │ │ │ │ ├── helpCommand.ts │ │ │ │ │ ├── hooksCommand.test.ts │ │ │ │ │ ├── hooksCommand.ts │ │ │ │ │ ├── ideCommand.test.ts │ │ │ │ │ ├── ideCommand.ts │ │ │ │ │ ├── initCommand.test.ts │ │ │ │ │ ├── initCommand.ts │ │ │ │ │ ├── mcpCommand.test.ts │ │ │ │ │ ├── mcpCommand.ts │ │ │ │ │ ├── memoryCommand.test.ts │ │ │ │ │ ├── memoryCommand.ts │ │ │ │ │ ├── modelCommand.test.ts │ │ │ │ │ ├── modelCommand.ts │ │ │ │ │ ├── oncallCommand.tsx │ │ │ │ │ ├── permissionsCommand.test.ts │ │ │ │ │ ├── permissionsCommand.ts │ │ │ │ │ ├── planCommand.test.ts │ │ │ │ │ ├── planCommand.ts │ │ │ │ │ ├── policiesCommand.test.ts │ │ │ │ │ ├── policiesCommand.ts │ │ │ │ │ ├── privacyCommand.test.ts │ │ │ │ │ ├── privacyCommand.ts │ │ │ │ │ ├── profileCommand.ts │ │ │ │ │ ├── quitCommand.test.ts │ │ │ │ │ ├── quitCommand.ts │ │ │ │ │ ├── restoreCommand.test.ts │ │ │ │ │ ├── restoreCommand.ts │ │ │ │ │ ├── resumeCommand.test.ts │ │ │ │ │ ├── resumeCommand.ts │ │ │ │ │ ├── rewindCommand.test.tsx │ │ │ │ │ ├── rewindCommand.tsx │ │ │ │ │ ├── settingsCommand.test.ts │ │ │ │ │ ├── settingsCommand.ts │ │ │ │ │ ├── setupGithubCommand.test.ts │ │ │ │ │ ├── setupGithubCommand.ts │ │ │ │ │ ├── shellsCommand.test.ts │ │ │ │ │ ├── shellsCommand.ts │ │ │ │ │ ├── shortcutsCommand.ts │ │ │ │ │ ├── skillsCommand.test.ts │ │ │ │ │ ├── skillsCommand.ts │ │ │ │ │ ├── statsCommand.test.ts │ │ │ │ │ ├── statsCommand.ts │ │ │ │ │ ├── terminalSetupCommand.test.ts │ │ │ │ │ ├── terminalSetupCommand.ts │ │ │ │ │ ├── themeCommand.test.ts │ │ │ │ │ ├── themeCommand.ts │ │ │ │ │ ├── toolsCommand.test.ts │ │ │ │ │ ├── toolsCommand.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── upgradeCommand.test.ts │ │ │ │ │ ├── upgradeCommand.ts │ │ │ │ │ └── vimCommand.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── AboutBox.test.tsx │ │ │ │ │ ├── AboutBox.tsx │ │ │ │ │ ├── AdminSettingsChangedDialog.test.tsx │ │ │ │ │ ├── AdminSettingsChangedDialog.tsx │ │ │ │ │ ├── AgentConfigDialog.test.tsx │ │ │ │ │ ├── AgentConfigDialog.tsx │ │ │ │ │ ├── AlternateBufferQuittingDisplay.test.tsx │ │ │ │ │ ├── AlternateBufferQuittingDisplay.tsx │ │ │ │ │ ├── AnsiOutput.test.tsx │ │ │ │ │ ├── AnsiOutput.tsx │ │ │ │ │ ├── AppHeader.test.tsx │ │ │ │ │ ├── AppHeader.tsx │ │ │ │ │ ├── AppHeaderIcon.test.tsx │ │ │ │ │ ├── ApprovalModeIndicator.test.tsx │ │ │ │ │ ├── ApprovalModeIndicator.tsx │ │ │ │ │ ├── AsciiArt.ts │ │ │ │ │ ├── AskUserDialog.test.tsx │ │ │ │ │ ├── AskUserDialog.tsx │ │ │ │ │ ├── BackgroundShellDisplay.test.tsx │ │ │ │ │ ├── BackgroundShellDisplay.tsx │ │ │ │ │ ├── Banner.test.tsx │ │ │ │ │ ├── Banner.tsx │ │ │ │ │ ├── BubblingRegression.test.tsx │ │ │ │ │ ├── Checklist.test.tsx │ │ │ │ │ ├── Checklist.tsx │ │ │ │ │ ├── ChecklistItem.test.tsx │ │ │ │ │ ├── ChecklistItem.tsx │ │ │ │ │ ├── CliSpinner.test.tsx │ │ │ │ │ ├── CliSpinner.tsx │ │ │ │ │ ├── ColorsDisplay.test.tsx │ │ │ │ │ ├── ColorsDisplay.tsx │ │ │ │ │ ├── Composer.test.tsx │ │ │ │ │ ├── Composer.tsx │ │ │ │ │ ├── ConfigExtensionDialog.tsx │ │ │ │ │ ├── ConfigInitDisplay.test.tsx │ │ │ │ │ ├── ConfigInitDisplay.tsx │ │ │ │ │ ├── ConsentPrompt.test.tsx │ │ │ │ │ ├── ConsentPrompt.tsx │ │ │ │ │ ├── ConsoleSummaryDisplay.test.tsx │ │ │ │ │ ├── ConsoleSummaryDisplay.tsx │ │ │ │ │ ├── ContextSummaryDisplay.test.tsx │ │ │ │ │ ├── ContextSummaryDisplay.tsx │ │ │ │ │ ├── ContextUsageDisplay.test.tsx │ │ │ │ │ ├── ContextUsageDisplay.tsx │ │ │ │ │ ├── CopyModeWarning.test.tsx │ │ │ │ │ ├── CopyModeWarning.tsx │ │ │ │ │ ├── DebugProfiler.test.tsx │ │ │ │ │ ├── DebugProfiler.tsx │ │ │ │ │ ├── DetailedMessagesDisplay.test.tsx │ │ │ │ │ ├── DetailedMessagesDisplay.tsx │ │ │ │ │ ├── DialogManager.test.tsx │ │ │ │ │ ├── DialogManager.tsx │ │ │ │ │ ├── EditorSettingsDialog.test.tsx │ │ │ │ │ ├── EditorSettingsDialog.tsx │ │ │ │ │ ├── EmptyWalletDialog.test.tsx │ │ │ │ │ ├── EmptyWalletDialog.tsx │ │ │ │ │ ├── ExitPlanModeDialog.test.tsx │ │ │ │ │ ├── ExitPlanModeDialog.tsx │ │ │ │ │ ├── ExitWarning.test.tsx │ │ │ │ │ ├── ExitWarning.tsx │ │ │ │ │ ├── FolderTrustDialog.test.tsx │ │ │ │ │ ├── FolderTrustDialog.tsx │ │ │ │ │ ├── Footer.test.tsx │ │ │ │ │ ├── Footer.tsx │ │ │ │ │ ├── FooterConfigDialog.test.tsx │ │ │ │ │ ├── FooterConfigDialog.tsx │ │ │ │ │ ├── GeminiRespondingSpinner.test.tsx │ │ │ │ │ ├── GeminiRespondingSpinner.tsx │ │ │ │ │ ├── GeminiSpinner.tsx │ │ │ │ │ ├── GradientRegression.test.tsx │ │ │ │ │ ├── Header.test.tsx │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── Help.test.tsx │ │ │ │ │ ├── Help.tsx │ │ │ │ │ ├── HistoryItemDisplay.test.tsx │ │ │ │ │ ├── HistoryItemDisplay.tsx │ │ │ │ │ ├── HookStatusDisplay.test.tsx │ │ │ │ │ ├── HookStatusDisplay.tsx │ │ │ │ │ ├── HooksDialog.test.tsx │ │ │ │ │ ├── HooksDialog.tsx │ │ │ │ │ ├── IdeTrustChangeDialog.test.tsx │ │ │ │ │ ├── IdeTrustChangeDialog.tsx │ │ │ │ │ ├── InputPrompt.test.tsx │ │ │ │ │ ├── InputPrompt.tsx │ │ │ │ │ ├── LoadingIndicator.test.tsx │ │ │ │ │ ├── LoadingIndicator.tsx │ │ │ │ │ ├── LogoutConfirmationDialog.test.tsx │ │ │ │ │ ├── LogoutConfirmationDialog.tsx │ │ │ │ │ ├── LoopDetectionConfirmation.test.tsx │ │ │ │ │ ├── LoopDetectionConfirmation.tsx │ │ │ │ │ ├── MainContent.test.tsx │ │ │ │ │ ├── MainContent.tsx │ │ │ │ │ ├── MemoryUsageDisplay.test.tsx │ │ │ │ │ ├── MemoryUsageDisplay.tsx │ │ │ │ │ ├── ModelDialog.test.tsx │ │ │ │ │ ├── ModelDialog.tsx │ │ │ │ │ ├── ModelStatsDisplay.test.tsx │ │ │ │ │ ├── ModelStatsDisplay.tsx │ │ │ │ │ ├── MultiFolderTrustDialog.test.tsx │ │ │ │ │ ├── MultiFolderTrustDialog.tsx │ │ │ │ │ ├── NewAgentsNotification.test.tsx │ │ │ │ │ ├── NewAgentsNotification.tsx │ │ │ │ │ ├── Notifications.test.tsx │ │ │ │ │ ├── Notifications.tsx │ │ │ │ │ ├── OverageMenuDialog.test.tsx │ │ │ │ │ ├── OverageMenuDialog.tsx │ │ │ │ │ ├── PermissionsModifyTrustDialog.test.tsx │ │ │ │ │ ├── PermissionsModifyTrustDialog.tsx │ │ │ │ │ ├── PolicyUpdateDialog.test.tsx │ │ │ │ │ ├── PolicyUpdateDialog.tsx │ │ │ │ │ ├── ProQuotaDialog.test.tsx │ │ │ │ │ ├── ProQuotaDialog.tsx │ │ │ │ │ ├── QueuedMessageDisplay.test.tsx │ │ │ │ │ ├── QueuedMessageDisplay.tsx │ │ │ │ │ ├── QuittingDisplay.test.tsx │ │ │ │ │ ├── QuittingDisplay.tsx │ │ │ │ │ ├── QuotaDisplay.test.tsx │ │ │ │ │ ├── QuotaDisplay.tsx │ │ │ │ │ ├── QuotaStatsInfo.tsx │ │ │ │ │ ├── RawMarkdownIndicator.test.tsx │ │ │ │ │ ├── RawMarkdownIndicator.tsx │ │ │ │ │ ├── RewindConfirmation.test.tsx │ │ │ │ │ ├── RewindConfirmation.tsx │ │ │ │ │ ├── RewindViewer.test.tsx │ │ │ │ │ ├── RewindViewer.tsx │ │ │ │ │ ├── SessionBrowser/ │ │ │ │ │ │ ├── SessionBrowserEmpty.tsx │ │ │ │ │ │ ├── SessionBrowserError.tsx │ │ │ │ │ │ ├── SessionBrowserLoading.tsx │ │ │ │ │ │ ├── SessionBrowserNav.tsx │ │ │ │ │ │ ├── SessionBrowserSearchNav.test.tsx │ │ │ │ │ │ ├── SessionBrowserStates.test.tsx │ │ │ │ │ │ ├── SessionListHeader.tsx │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ ├── SessionBrowserSearchNav.test.tsx.snap │ │ │ │ │ │ │ └── SessionBrowserStates.test.tsx.snap │ │ │ │ │ │ ├── utils.test.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── SessionBrowser.test.tsx │ │ │ │ │ ├── SessionBrowser.tsx │ │ │ │ │ ├── SessionSummaryDisplay.test.tsx │ │ │ │ │ ├── SessionSummaryDisplay.tsx │ │ │ │ │ ├── SettingsDialog.test.tsx │ │ │ │ │ ├── SettingsDialog.tsx │ │ │ │ │ ├── ShellInputPrompt.test.tsx │ │ │ │ │ ├── ShellInputPrompt.tsx │ │ │ │ │ ├── ShellModeIndicator.test.tsx │ │ │ │ │ ├── ShellModeIndicator.tsx │ │ │ │ │ ├── ShortcutsHelp.test.tsx │ │ │ │ │ ├── ShortcutsHelp.tsx │ │ │ │ │ ├── ShortcutsHint.tsx │ │ │ │ │ ├── ShowMoreLines.test.tsx │ │ │ │ │ ├── ShowMoreLines.tsx │ │ │ │ │ ├── ShowMoreLinesLayout.test.tsx │ │ │ │ │ ├── StatsDisplay.test.tsx │ │ │ │ │ ├── StatsDisplay.tsx │ │ │ │ │ ├── StatusDisplay.test.tsx │ │ │ │ │ ├── StatusDisplay.tsx │ │ │ │ │ ├── StickyHeader.test.tsx │ │ │ │ │ ├── StickyHeader.tsx │ │ │ │ │ ├── SuggestionsDisplay.test.tsx │ │ │ │ │ ├── SuggestionsDisplay.tsx │ │ │ │ │ ├── Table.test.tsx │ │ │ │ │ ├── Table.tsx │ │ │ │ │ ├── ThemeDialog.test.tsx │ │ │ │ │ ├── ThemeDialog.tsx │ │ │ │ │ ├── ThemedGradient.test.tsx │ │ │ │ │ ├── ThemedGradient.tsx │ │ │ │ │ ├── Tips.test.tsx │ │ │ │ │ ├── Tips.tsx │ │ │ │ │ ├── ToastDisplay.test.tsx │ │ │ │ │ ├── ToastDisplay.tsx │ │ │ │ │ ├── ToolConfirmationQueue.test.tsx │ │ │ │ │ ├── ToolConfirmationQueue.tsx │ │ │ │ │ ├── ToolStatsDisplay.test.tsx │ │ │ │ │ ├── ToolStatsDisplay.tsx │ │ │ │ │ ├── UpdateNotification.test.tsx │ │ │ │ │ ├── UpdateNotification.tsx │ │ │ │ │ ├── UserIdentity.test.tsx │ │ │ │ │ ├── UserIdentity.tsx │ │ │ │ │ ├── ValidationDialog.test.tsx │ │ │ │ │ ├── ValidationDialog.tsx │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ ├── AdminSettingsChangedDialog.test.tsx.snap │ │ │ │ │ │ ├── AlternateBufferQuittingDisplay.test.tsx.snap │ │ │ │ │ │ ├── AppHeader.test.tsx.snap │ │ │ │ │ │ ├── AppHeaderIcon.test.tsx.snap │ │ │ │ │ │ ├── ApprovalModeIndicator.test.tsx.snap │ │ │ │ │ │ ├── AskUserDialog.test.tsx.snap │ │ │ │ │ │ ├── BackgroundShellDisplay.test.tsx.snap │ │ │ │ │ │ ├── Banner.test.tsx.snap │ │ │ │ │ │ ├── Checklist.test.tsx.snap │ │ │ │ │ │ ├── ChecklistItem.test.tsx.snap │ │ │ │ │ │ ├── Composer.test.tsx.snap │ │ │ │ │ │ ├── ConfigInitDisplay.test.tsx.snap │ │ │ │ │ │ ├── ContextSummaryDisplay.test.tsx.snap │ │ │ │ │ │ ├── DetailedMessagesDisplay.test.tsx.snap │ │ │ │ │ │ ├── EditorSettingsDialog.test.tsx.snap │ │ │ │ │ │ ├── EmptyWalletDialog.test.tsx.snap │ │ │ │ │ │ ├── ExitPlanModeDialog.test.tsx.snap │ │ │ │ │ │ ├── Footer.test.tsx.snap │ │ │ │ │ │ ├── FooterConfigDialog.test.tsx.snap │ │ │ │ │ │ ├── HistoryItemDisplay.test.tsx.snap │ │ │ │ │ │ ├── HookStatusDisplay.test.tsx.snap │ │ │ │ │ │ ├── HooksDialog.test.tsx.snap │ │ │ │ │ │ ├── IDEContextDetailDisplay.test.tsx.snap │ │ │ │ │ │ ├── InputPrompt.test.tsx.snap │ │ │ │ │ │ ├── LoadingIndicator.test.tsx.snap │ │ │ │ │ │ ├── LoopDetectionConfirmation.test.tsx.snap │ │ │ │ │ │ ├── MainContent.test.tsx.snap │ │ │ │ │ │ ├── ModelStatsDisplay.test.tsx.snap │ │ │ │ │ │ ├── NewAgentsNotification.test.tsx.snap │ │ │ │ │ │ ├── Notifications.test.tsx.snap │ │ │ │ │ │ ├── OverageMenuDialog.test.tsx.snap │ │ │ │ │ │ ├── PolicyUpdateDialog.test.tsx.snap │ │ │ │ │ │ ├── PrepareLabel.test.tsx.snap │ │ │ │ │ │ ├── QuotaDisplay.test.tsx.snap │ │ │ │ │ │ ├── RewindConfirmation.test.tsx.snap │ │ │ │ │ │ ├── RewindViewer.test.tsx.snap │ │ │ │ │ │ ├── SessionBrowser.test.tsx.snap │ │ │ │ │ │ ├── SessionSummaryDisplay.test.tsx.snap │ │ │ │ │ │ ├── SettingsDialog.test.tsx.snap │ │ │ │ │ │ ├── ShortcutsHelp.test.tsx.snap │ │ │ │ │ │ ├── StatsDisplay.test.tsx.snap │ │ │ │ │ │ ├── StatusDisplay.test.tsx.snap │ │ │ │ │ │ ├── SuggestionsDisplay.test.tsx.snap │ │ │ │ │ │ ├── Table.test.tsx.snap │ │ │ │ │ │ ├── ThemeDialog.test.tsx.snap │ │ │ │ │ │ ├── Tips.test.tsx.snap │ │ │ │ │ │ ├── ToastDisplay.test.tsx.snap │ │ │ │ │ │ ├── ToolConfirmationQueue.test.tsx.snap │ │ │ │ │ │ └── ToolStatsDisplay.test.tsx.snap │ │ │ │ │ ├── messages/ │ │ │ │ │ │ ├── CompressionMessage.test.tsx │ │ │ │ │ │ ├── CompressionMessage.tsx │ │ │ │ │ │ ├── DiffRenderer.test.tsx │ │ │ │ │ │ ├── DiffRenderer.tsx │ │ │ │ │ │ ├── ErrorMessage.test.tsx │ │ │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ │ │ ├── GeminiMessage.test.tsx │ │ │ │ │ │ ├── GeminiMessage.tsx │ │ │ │ │ │ ├── GeminiMessageContent.tsx │ │ │ │ │ │ ├── HintMessage.tsx │ │ │ │ │ │ ├── InfoMessage.test.tsx │ │ │ │ │ │ ├── InfoMessage.tsx │ │ │ │ │ │ ├── ModelMessage.tsx │ │ │ │ │ │ ├── RedirectionConfirmation.test.tsx │ │ │ │ │ │ ├── ShellToolMessage.test.tsx │ │ │ │ │ │ ├── ShellToolMessage.tsx │ │ │ │ │ │ ├── SubagentGroupDisplay.test.tsx │ │ │ │ │ │ ├── SubagentGroupDisplay.tsx │ │ │ │ │ │ ├── SubagentProgressDisplay.test.tsx │ │ │ │ │ │ ├── SubagentProgressDisplay.tsx │ │ │ │ │ │ ├── ThinkingMessage.test.tsx │ │ │ │ │ │ ├── ThinkingMessage.tsx │ │ │ │ │ │ ├── Todo.test.tsx │ │ │ │ │ │ ├── Todo.tsx │ │ │ │ │ │ ├── ToolConfirmationMessage.test.tsx │ │ │ │ │ │ ├── ToolConfirmationMessage.tsx │ │ │ │ │ │ ├── ToolGroupMessage.test.tsx │ │ │ │ │ │ ├── ToolGroupMessage.tsx │ │ │ │ │ │ ├── ToolMessage.test.tsx │ │ │ │ │ │ ├── ToolMessage.tsx │ │ │ │ │ │ ├── ToolMessageFocusHint.test.tsx │ │ │ │ │ │ ├── ToolMessageRawMarkdown.test.tsx │ │ │ │ │ │ ├── ToolOverflowConsistencyChecks.test.tsx │ │ │ │ │ │ ├── ToolResultDisplay.test.tsx │ │ │ │ │ │ ├── ToolResultDisplay.tsx │ │ │ │ │ │ ├── ToolResultDisplayOverflow.test.tsx │ │ │ │ │ │ ├── ToolShared.test.tsx │ │ │ │ │ │ ├── ToolShared.tsx │ │ │ │ │ │ ├── ToolStickyHeaderRegression.test.tsx │ │ │ │ │ │ ├── UserMessage.test.tsx │ │ │ │ │ │ ├── UserMessage.tsx │ │ │ │ │ │ ├── UserShellMessage.tsx │ │ │ │ │ │ ├── WarningMessage.test.tsx │ │ │ │ │ │ ├── WarningMessage.tsx │ │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ │ ├── DiffRenderer.test.tsx.snap │ │ │ │ │ │ ├── ErrorMessage.test.tsx.snap │ │ │ │ │ │ ├── GeminiMessage.test.tsx.snap │ │ │ │ │ │ ├── InfoMessage.test.tsx.snap │ │ │ │ │ │ ├── RedirectionConfirmation.test.tsx.snap │ │ │ │ │ │ ├── ShellToolMessage.test.tsx.snap │ │ │ │ │ │ ├── SubagentGroupDisplay.test.tsx.snap │ │ │ │ │ │ ├── SubagentProgressDisplay.test.tsx.snap │ │ │ │ │ │ ├── ThinkingMessage.test.tsx.snap │ │ │ │ │ │ ├── Todo.test.tsx.snap │ │ │ │ │ │ ├── ToolConfirmationMessage.test.tsx.snap │ │ │ │ │ │ ├── ToolConfirmationMessageOverflow.test.tsx.snap │ │ │ │ │ │ ├── ToolGroupMessage.test.tsx.snap │ │ │ │ │ │ ├── ToolMessage.test.tsx.snap │ │ │ │ │ │ ├── ToolMessageFocusHint.test.tsx.snap │ │ │ │ │ │ ├── ToolMessageRawMarkdown.test.tsx.snap │ │ │ │ │ │ ├── ToolResultDisplay.test.tsx.snap │ │ │ │ │ │ ├── ToolShared.test.tsx.snap │ │ │ │ │ │ ├── ToolStickyHeaderRegression.test.tsx.snap │ │ │ │ │ │ ├── UserMessage.test.tsx.snap │ │ │ │ │ │ └── WarningMessage.test.tsx.snap │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── BaseSelectionList.test.tsx │ │ │ │ │ │ ├── BaseSelectionList.tsx │ │ │ │ │ │ ├── BaseSettingsDialog.test.tsx │ │ │ │ │ │ ├── BaseSettingsDialog.tsx │ │ │ │ │ │ ├── DescriptiveRadioButtonSelect.test.tsx │ │ │ │ │ │ ├── DescriptiveRadioButtonSelect.tsx │ │ │ │ │ │ ├── DialogFooter.tsx │ │ │ │ │ │ ├── EnumSelector.test.tsx │ │ │ │ │ │ ├── EnumSelector.tsx │ │ │ │ │ │ ├── ExpandableText.test.tsx │ │ │ │ │ │ ├── ExpandableText.tsx │ │ │ │ │ │ ├── HalfLinePaddedBox.test.tsx │ │ │ │ │ │ ├── HalfLinePaddedBox.tsx │ │ │ │ │ │ ├── HorizontalLine.tsx │ │ │ │ │ │ ├── MaxSizedBox.test.tsx │ │ │ │ │ │ ├── MaxSizedBox.tsx │ │ │ │ │ │ ├── RadioButtonSelect.test.tsx │ │ │ │ │ │ ├── RadioButtonSelect.tsx │ │ │ │ │ │ ├── ScopeSelector.tsx │ │ │ │ │ │ ├── Scrollable.test.tsx │ │ │ │ │ │ ├── Scrollable.tsx │ │ │ │ │ │ ├── ScrollableList.test.tsx │ │ │ │ │ │ ├── ScrollableList.tsx │ │ │ │ │ │ ├── SearchableList.test.tsx │ │ │ │ │ │ ├── SearchableList.tsx │ │ │ │ │ │ ├── SectionHeader.test.tsx │ │ │ │ │ │ ├── SectionHeader.tsx │ │ │ │ │ │ ├── SlicingMaxSizedBox.test.tsx │ │ │ │ │ │ ├── SlicingMaxSizedBox.tsx │ │ │ │ │ │ ├── TabHeader.test.tsx │ │ │ │ │ │ ├── TabHeader.tsx │ │ │ │ │ │ ├── TextInput.test.tsx │ │ │ │ │ │ ├── TextInput.tsx │ │ │ │ │ │ ├── VirtualizedList.test.tsx │ │ │ │ │ │ ├── VirtualizedList.tsx │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ ├── BaseSelectionList.test.tsx.snap │ │ │ │ │ │ │ ├── DescriptiveRadioButtonSelect.test.tsx.snap │ │ │ │ │ │ │ ├── EnumSelector.test.tsx.snap │ │ │ │ │ │ │ ├── ExpandablePrompt.test.tsx.snap │ │ │ │ │ │ │ ├── ExpandableText.test.tsx.snap │ │ │ │ │ │ │ ├── HalfLinePaddedBox.test.tsx.snap │ │ │ │ │ │ │ ├── MaxSizedBox.test.tsx.snap │ │ │ │ │ │ │ ├── Scrollable.test.tsx.snap │ │ │ │ │ │ │ ├── SearchableList.test.tsx.snap │ │ │ │ │ │ │ ├── SectionHeader.test.tsx.snap │ │ │ │ │ │ │ ├── TabHeader.test.tsx.snap │ │ │ │ │ │ │ └── VirtualizedList.test.tsx.snap │ │ │ │ │ │ ├── performance.test.ts │ │ │ │ │ │ ├── text-buffer.test.ts │ │ │ │ │ │ ├── text-buffer.ts │ │ │ │ │ │ ├── vim-buffer-actions.test.ts │ │ │ │ │ │ └── vim-buffer-actions.ts │ │ │ │ │ ├── triage/ │ │ │ │ │ │ ├── TriageDuplicates.tsx │ │ │ │ │ │ └── TriageIssues.tsx │ │ │ │ │ └── views/ │ │ │ │ │ ├── AgentsStatus.tsx │ │ │ │ │ ├── ChatList.test.tsx │ │ │ │ │ ├── ChatList.tsx │ │ │ │ │ ├── ExtensionDetails.test.tsx │ │ │ │ │ ├── ExtensionDetails.tsx │ │ │ │ │ ├── ExtensionRegistryView.test.tsx │ │ │ │ │ ├── ExtensionRegistryView.tsx │ │ │ │ │ ├── ExtensionsList.test.tsx │ │ │ │ │ ├── ExtensionsList.tsx │ │ │ │ │ ├── McpStatus.test.tsx │ │ │ │ │ ├── McpStatus.tsx │ │ │ │ │ ├── SkillsList.test.tsx │ │ │ │ │ ├── SkillsList.tsx │ │ │ │ │ ├── ToolsList.test.tsx │ │ │ │ │ ├── ToolsList.tsx │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ ├── ChatList.test.tsx.snap │ │ │ │ │ ├── McpStatus.test.tsx.snap │ │ │ │ │ └── ToolsList.test.tsx.snap │ │ │ │ ├── constants/ │ │ │ │ │ ├── tips.ts │ │ │ │ │ └── wittyPhrases.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── contexts/ │ │ │ │ │ ├── AppContext.tsx │ │ │ │ │ ├── AskUserActionsContext.tsx │ │ │ │ │ ├── ConfigContext.tsx │ │ │ │ │ ├── KeypressContext.test.tsx │ │ │ │ │ ├── KeypressContext.tsx │ │ │ │ │ ├── MouseContext.test.tsx │ │ │ │ │ ├── MouseContext.tsx │ │ │ │ │ ├── OverflowContext.tsx │ │ │ │ │ ├── ScrollProvider.drag.test.tsx │ │ │ │ │ ├── ScrollProvider.test.tsx │ │ │ │ │ ├── ScrollProvider.tsx │ │ │ │ │ ├── SessionContext.test.tsx │ │ │ │ │ ├── SessionContext.tsx │ │ │ │ │ ├── SettingsContext.test.tsx │ │ │ │ │ ├── SettingsContext.tsx │ │ │ │ │ ├── ShellFocusContext.tsx │ │ │ │ │ ├── StreamingContext.tsx │ │ │ │ │ ├── TerminalContext.test.tsx │ │ │ │ │ ├── TerminalContext.tsx │ │ │ │ │ ├── ToolActionsContext.test.tsx │ │ │ │ │ ├── ToolActionsContext.tsx │ │ │ │ │ ├── UIActionsContext.tsx │ │ │ │ │ ├── UIStateContext.tsx │ │ │ │ │ └── VimModeContext.tsx │ │ │ │ ├── debug.ts │ │ │ │ ├── editors/ │ │ │ │ │ └── editorSettingsManager.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── usePhraseCycler.test.tsx.snap │ │ │ │ │ ├── atCommandProcessor.test.ts │ │ │ │ │ ├── atCommandProcessor.ts │ │ │ │ │ ├── atCommandProcessor_agents.test.ts │ │ │ │ │ ├── creditsFlowHandler.test.ts │ │ │ │ │ ├── creditsFlowHandler.ts │ │ │ │ │ ├── shell-completions/ │ │ │ │ │ │ ├── gitProvider.test.ts │ │ │ │ │ │ ├── gitProvider.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── npmProvider.test.ts │ │ │ │ │ │ ├── npmProvider.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── shellCommandProcessor.test.tsx │ │ │ │ │ ├── shellCommandProcessor.ts │ │ │ │ │ ├── shellReducer.test.ts │ │ │ │ │ ├── shellReducer.ts │ │ │ │ │ ├── slashCommandProcessor.test.tsx │ │ │ │ │ ├── slashCommandProcessor.ts │ │ │ │ │ ├── toolMapping.test.ts │ │ │ │ │ ├── toolMapping.ts │ │ │ │ │ ├── useAlternateBuffer.test.ts │ │ │ │ │ ├── useAlternateBuffer.ts │ │ │ │ │ ├── useAnimatedScrollbar.test.tsx │ │ │ │ │ ├── useAnimatedScrollbar.ts │ │ │ │ │ ├── useApprovalModeIndicator.test.ts │ │ │ │ │ ├── useApprovalModeIndicator.ts │ │ │ │ │ ├── useAtCompletion.test.ts │ │ │ │ │ ├── useAtCompletion.ts │ │ │ │ │ ├── useAtCompletion_agents.test.ts │ │ │ │ │ ├── useBackgroundShellManager.test.tsx │ │ │ │ │ ├── useBackgroundShellManager.ts │ │ │ │ │ ├── useBanner.test.ts │ │ │ │ │ ├── useBanner.ts │ │ │ │ │ ├── useBatchedScroll.test.ts │ │ │ │ │ ├── useBatchedScroll.ts │ │ │ │ │ ├── useCommandCompletion.test.tsx │ │ │ │ │ ├── useCommandCompletion.tsx │ │ │ │ │ ├── useCompletion.ts │ │ │ │ │ ├── useConfirmingTool.ts │ │ │ │ │ ├── useConsoleMessages.test.tsx │ │ │ │ │ ├── useConsoleMessages.ts │ │ │ │ │ ├── useEditorSettings.test.tsx │ │ │ │ │ ├── useEditorSettings.ts │ │ │ │ │ ├── useExtensionRegistry.ts │ │ │ │ │ ├── useExtensionUpdates.test.tsx │ │ │ │ │ ├── useExtensionUpdates.ts │ │ │ │ │ ├── useFlickerDetector.test.ts │ │ │ │ │ ├── useFlickerDetector.ts │ │ │ │ │ ├── useFocus.test.tsx │ │ │ │ │ ├── useFocus.ts │ │ │ │ │ ├── useFolderTrust.test.ts │ │ │ │ │ ├── useFolderTrust.ts │ │ │ │ │ ├── useGeminiStream.test.tsx │ │ │ │ │ ├── useGeminiStream.ts │ │ │ │ │ ├── useGitBranchName.test.tsx │ │ │ │ │ ├── useGitBranchName.ts │ │ │ │ │ ├── useHistoryManager.test.ts │ │ │ │ │ ├── useHistoryManager.ts │ │ │ │ │ ├── useHookDisplayState.test.ts │ │ │ │ │ ├── useHookDisplayState.ts │ │ │ │ │ ├── useIdeTrustListener.test.tsx │ │ │ │ │ ├── useIdeTrustListener.ts │ │ │ │ │ ├── useInactivityTimer.ts │ │ │ │ │ ├── useIncludeDirsTrust.test.tsx │ │ │ │ │ ├── useIncludeDirsTrust.tsx │ │ │ │ │ ├── useInlineEditBuffer.test.ts │ │ │ │ │ ├── useInlineEditBuffer.ts │ │ │ │ │ ├── useInputHistory.test.ts │ │ │ │ │ ├── useInputHistory.ts │ │ │ │ │ ├── useInputHistoryStore.test.ts │ │ │ │ │ ├── useInputHistoryStore.ts │ │ │ │ │ ├── useKeyMatchers.tsx │ │ │ │ │ ├── useKeypress.test.tsx │ │ │ │ │ ├── useKeypress.ts │ │ │ │ │ ├── useKittyKeyboardProtocol.ts │ │ │ │ │ ├── useLoadingIndicator.test.tsx │ │ │ │ │ ├── useLoadingIndicator.ts │ │ │ │ │ ├── useLogger.test.tsx │ │ │ │ │ ├── useLogger.ts │ │ │ │ │ ├── useMcpStatus.test.tsx │ │ │ │ │ ├── useMcpStatus.ts │ │ │ │ │ ├── useMemoryMonitor.test.tsx │ │ │ │ │ ├── useMemoryMonitor.ts │ │ │ │ │ ├── useMessageQueue.test.tsx │ │ │ │ │ ├── useMessageQueue.ts │ │ │ │ │ ├── useModelCommand.test.tsx │ │ │ │ │ ├── useModelCommand.ts │ │ │ │ │ ├── useMouse.test.ts │ │ │ │ │ ├── useMouse.ts │ │ │ │ │ ├── useMouseClick.test.ts │ │ │ │ │ ├── useMouseClick.ts │ │ │ │ │ ├── usePermissionsModifyTrust.test.ts │ │ │ │ │ ├── usePermissionsModifyTrust.ts │ │ │ │ │ ├── usePhraseCycler.test.tsx │ │ │ │ │ ├── usePhraseCycler.ts │ │ │ │ │ ├── usePrivacySettings.test.tsx │ │ │ │ │ ├── usePrivacySettings.ts │ │ │ │ │ ├── usePromptCompletion.ts │ │ │ │ │ ├── useQuotaAndFallback.test.ts │ │ │ │ │ ├── useQuotaAndFallback.ts │ │ │ │ │ ├── useRegistrySearch.ts │ │ │ │ │ ├── useRepeatedKeyPress.ts │ │ │ │ │ ├── useReverseSearchCompletion.test.tsx │ │ │ │ │ ├── useReverseSearchCompletion.tsx │ │ │ │ │ ├── useRewind.test.ts │ │ │ │ │ ├── useRewind.ts │ │ │ │ │ ├── useRunEventNotifications.ts │ │ │ │ │ ├── useSearchBuffer.ts │ │ │ │ │ ├── useSelectionList.test.tsx │ │ │ │ │ ├── useSelectionList.ts │ │ │ │ │ ├── useSessionBrowser.test.ts │ │ │ │ │ ├── useSessionBrowser.ts │ │ │ │ │ ├── useSessionResume.test.ts │ │ │ │ │ ├── useSessionResume.ts │ │ │ │ │ ├── useSettingsCommand.ts │ │ │ │ │ ├── useSettingsNavigation.test.ts │ │ │ │ │ ├── useSettingsNavigation.ts │ │ │ │ │ ├── useShellCompletion.test.ts │ │ │ │ │ ├── useShellCompletion.ts │ │ │ │ │ ├── useShellHistory.test.ts │ │ │ │ │ ├── useShellHistory.ts │ │ │ │ │ ├── useShellInactivityStatus.test.ts │ │ │ │ │ ├── useShellInactivityStatus.ts │ │ │ │ │ ├── useSlashCompletion.test.ts │ │ │ │ │ ├── useSlashCompletion.ts │ │ │ │ │ ├── useSnowfall.test.tsx │ │ │ │ │ ├── useSnowfall.ts │ │ │ │ │ ├── useStateAndRef.ts │ │ │ │ │ ├── useSuspend.test.ts │ │ │ │ │ ├── useSuspend.ts │ │ │ │ │ ├── useTabbedNavigation.test.ts │ │ │ │ │ ├── useTabbedNavigation.ts │ │ │ │ │ ├── useTerminalSize.ts │ │ │ │ │ ├── useTerminalTheme.test.tsx │ │ │ │ │ ├── useTerminalTheme.ts │ │ │ │ │ ├── useThemeCommand.ts │ │ │ │ │ ├── useTimedMessage.ts │ │ │ │ │ ├── useTimer.test.tsx │ │ │ │ │ ├── useTimer.ts │ │ │ │ │ ├── useTips.test.ts │ │ │ │ │ ├── useTips.ts │ │ │ │ │ ├── useToolScheduler.test.ts │ │ │ │ │ ├── useToolScheduler.ts │ │ │ │ │ ├── useTurnActivityMonitor.test.ts │ │ │ │ │ ├── useTurnActivityMonitor.ts │ │ │ │ │ ├── useVisibilityToggle.ts │ │ │ │ │ ├── vim-passthrough.test.tsx │ │ │ │ │ ├── vim.test.tsx │ │ │ │ │ └── vim.ts │ │ │ │ ├── key/ │ │ │ │ │ ├── keyBindings.test.ts │ │ │ │ │ ├── keyBindings.ts │ │ │ │ │ ├── keyMatchers.test.ts │ │ │ │ │ ├── keyMatchers.ts │ │ │ │ │ ├── keyToAnsi.ts │ │ │ │ │ ├── keybindingUtils.test.ts │ │ │ │ │ └── keybindingUtils.ts │ │ │ │ ├── layouts/ │ │ │ │ │ ├── DefaultAppLayout.test.tsx │ │ │ │ │ ├── DefaultAppLayout.tsx │ │ │ │ │ ├── ScreenReaderAppLayout.tsx │ │ │ │ │ └── __snapshots__/ │ │ │ │ │ └── DefaultAppLayout.test.tsx.snap │ │ │ │ ├── noninteractive/ │ │ │ │ │ └── nonInteractiveUi.ts │ │ │ │ ├── privacy/ │ │ │ │ │ ├── CloudFreePrivacyNotice.test.tsx │ │ │ │ │ ├── CloudFreePrivacyNotice.tsx │ │ │ │ │ ├── CloudPaidPrivacyNotice.test.tsx │ │ │ │ │ ├── CloudPaidPrivacyNotice.tsx │ │ │ │ │ ├── GeminiPrivacyNotice.test.tsx │ │ │ │ │ ├── GeminiPrivacyNotice.tsx │ │ │ │ │ ├── PrivacyNotice.test.tsx │ │ │ │ │ └── PrivacyNotice.tsx │ │ │ │ ├── semantic-colors.ts │ │ │ │ ├── state/ │ │ │ │ │ ├── extensions.test.ts │ │ │ │ │ └── extensions.ts │ │ │ │ ├── textConstants.ts │ │ │ │ ├── themes/ │ │ │ │ │ ├── builtin/ │ │ │ │ │ │ ├── dark/ │ │ │ │ │ │ │ ├── ansi-dark.ts │ │ │ │ │ │ │ ├── atom-one-dark.ts │ │ │ │ │ │ │ ├── ayu-dark.ts │ │ │ │ │ │ │ ├── default-dark.ts │ │ │ │ │ │ │ ├── dracula-dark.ts │ │ │ │ │ │ │ ├── github-dark.ts │ │ │ │ │ │ │ ├── holiday-dark.ts │ │ │ │ │ │ │ ├── shades-of-purple-dark.ts │ │ │ │ │ │ │ └── solarized-dark.ts │ │ │ │ │ │ ├── light/ │ │ │ │ │ │ │ ├── ansi-light.ts │ │ │ │ │ │ │ ├── ayu-light.ts │ │ │ │ │ │ │ ├── default-light.ts │ │ │ │ │ │ │ ├── github-light.ts │ │ │ │ │ │ │ ├── googlecode-light.ts │ │ │ │ │ │ │ ├── solarized-light.ts │ │ │ │ │ │ │ └── xcode-light.ts │ │ │ │ │ │ └── no-color.ts │ │ │ │ │ ├── color-utils.test.ts │ │ │ │ │ ├── color-utils.ts │ │ │ │ │ ├── semantic-tokens.ts │ │ │ │ │ ├── theme-manager.test.ts │ │ │ │ │ ├── theme-manager.ts │ │ │ │ │ ├── theme.test.ts │ │ │ │ │ └── theme.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── CodeColorizer.test.tsx │ │ │ │ ├── CodeColorizer.tsx │ │ │ │ ├── ConsolePatcher.ts │ │ │ │ ├── InlineMarkdownRenderer.tsx │ │ │ │ ├── MarkdownDisplay.test.tsx │ │ │ │ ├── MarkdownDisplay.tsx │ │ │ │ ├── TableRenderer.test.tsx │ │ │ │ ├── TableRenderer.tsx │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── CodeColorizer.test.tsx.snap │ │ │ │ │ ├── MarkdownDisplay.test.tsx.snap │ │ │ │ │ ├── TableRenderer.test.tsx.snap │ │ │ │ │ ├── borderStyles.test.tsx.snap │ │ │ │ │ ├── terminalSetup.test.ts.snap │ │ │ │ │ └── textOutput.test.ts.snap │ │ │ │ ├── borderStyles.test.tsx │ │ │ │ ├── borderStyles.ts │ │ │ │ ├── clipboardUtils.test.ts │ │ │ │ ├── clipboardUtils.ts │ │ │ │ ├── clipboardUtils.windows.test.ts │ │ │ │ ├── commandUtils.test.ts │ │ │ │ ├── commandUtils.ts │ │ │ │ ├── computeStats.test.ts │ │ │ │ ├── computeStats.ts │ │ │ │ ├── confirmingTool.ts │ │ │ │ ├── contextUsage.ts │ │ │ │ ├── directoryUtils.test.ts │ │ │ │ ├── directoryUtils.ts │ │ │ │ ├── displayUtils.test.ts │ │ │ │ ├── displayUtils.ts │ │ │ │ ├── editorUtils.ts │ │ │ │ ├── formatters.test.ts │ │ │ │ ├── formatters.ts │ │ │ │ ├── highlight.test.ts │ │ │ │ ├── highlight.ts │ │ │ │ ├── historyExportUtils.ts │ │ │ │ ├── inlineThinkingMode.ts │ │ │ │ ├── input.test.ts │ │ │ │ ├── input.ts │ │ │ │ ├── isNarrowWidth.ts │ │ │ │ ├── markdownParsingUtils.test.ts │ │ │ │ ├── markdownParsingUtils.ts │ │ │ │ ├── markdownUtilities.test.ts │ │ │ │ ├── markdownUtilities.ts │ │ │ │ ├── mouse.test.ts │ │ │ │ ├── mouse.ts │ │ │ │ ├── pendingAttentionNotification.test.ts │ │ │ │ ├── pendingAttentionNotification.ts │ │ │ │ ├── rewindFileOps.test.ts │ │ │ │ ├── rewindFileOps.ts │ │ │ │ ├── shortcutsHelp.ts │ │ │ │ ├── terminalCapabilityManager.test.ts │ │ │ │ ├── terminalCapabilityManager.ts │ │ │ │ ├── terminalSetup.test.ts │ │ │ │ ├── terminalSetup.ts │ │ │ │ ├── terminalUtils.ts │ │ │ │ ├── textOutput.test.ts │ │ │ │ ├── textOutput.ts │ │ │ │ ├── textUtils.test.ts │ │ │ │ ├── textUtils.ts │ │ │ │ ├── toolLayoutUtils.test.ts │ │ │ │ ├── toolLayoutUtils.ts │ │ │ │ ├── ui-sizing.test.ts │ │ │ │ ├── ui-sizing.ts │ │ │ │ ├── updateCheck.test.ts │ │ │ │ ├── updateCheck.ts │ │ │ │ ├── urlSecurityUtils.test.ts │ │ │ │ └── urlSecurityUtils.ts │ │ │ ├── utils/ │ │ │ │ ├── activityLogger.test.ts │ │ │ │ ├── activityLogger.ts │ │ │ │ ├── agentSettings.test.ts │ │ │ │ ├── agentSettings.ts │ │ │ │ ├── agentUtils.test.ts │ │ │ │ ├── agentUtils.ts │ │ │ │ ├── cleanup.test.ts │ │ │ │ ├── cleanup.ts │ │ │ │ ├── commands.test.ts │ │ │ │ ├── commands.ts │ │ │ │ ├── commentJson.test.ts │ │ │ │ ├── commentJson.ts │ │ │ │ ├── deepMerge.test.ts │ │ │ │ ├── deepMerge.ts │ │ │ │ ├── devtoolsService.test.ts │ │ │ │ ├── devtoolsService.ts │ │ │ │ ├── dialogScopeUtils.test.ts │ │ │ │ ├── dialogScopeUtils.ts │ │ │ │ ├── envVarResolver.test.ts │ │ │ │ ├── envVarResolver.ts │ │ │ │ ├── errors.test.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── events.test.ts │ │ │ │ ├── events.ts │ │ │ │ ├── featureToggleUtils.test.ts │ │ │ │ ├── featureToggleUtils.ts │ │ │ │ ├── gitUtils.test.ts │ │ │ │ ├── gitUtils.ts │ │ │ │ ├── handleAutoUpdate.test.ts │ │ │ │ ├── handleAutoUpdate.ts │ │ │ │ ├── hookSettings.test.ts │ │ │ │ ├── hookSettings.ts │ │ │ │ ├── hookUtils.test.ts │ │ │ │ ├── hookUtils.ts │ │ │ │ ├── installationInfo.test.ts │ │ │ │ ├── installationInfo.ts │ │ │ │ ├── jsonoutput.test.ts │ │ │ │ ├── jsonoutput.ts │ │ │ │ ├── logCleanup.test.ts │ │ │ │ ├── logCleanup.ts │ │ │ │ ├── math.test.ts │ │ │ │ ├── math.ts │ │ │ │ ├── persistentState.test.ts │ │ │ │ ├── persistentState.ts │ │ │ │ ├── processUtils.test.ts │ │ │ │ ├── processUtils.ts │ │ │ │ ├── readStdin.test.ts │ │ │ │ ├── readStdin.ts │ │ │ │ ├── readStdin_safety.test.ts │ │ │ │ ├── relaunch.test.ts │ │ │ │ ├── relaunch.ts │ │ │ │ ├── resolvePath.test.ts │ │ │ │ ├── resolvePath.ts │ │ │ │ ├── sandbox-macos-permissive-open.sb │ │ │ │ ├── sandbox-macos-permissive-proxied.sb │ │ │ │ ├── sandbox-macos-restrictive-open.sb │ │ │ │ ├── sandbox-macos-restrictive-proxied.sb │ │ │ │ ├── sandbox-macos-strict-open.sb │ │ │ │ ├── sandbox-macos-strict-proxied.sb │ │ │ │ ├── sandbox.test.ts │ │ │ │ ├── sandbox.ts │ │ │ │ ├── sandboxUtils.test.ts │ │ │ │ ├── sandboxUtils.ts │ │ │ │ ├── sessionCleanup.integration.test.ts │ │ │ │ ├── sessionCleanup.test.ts │ │ │ │ ├── sessionCleanup.ts │ │ │ │ ├── sessionUtils.test.ts │ │ │ │ ├── sessionUtils.ts │ │ │ │ ├── sessions.test.ts │ │ │ │ ├── sessions.ts │ │ │ │ ├── settingsUtils.test.ts │ │ │ │ ├── settingsUtils.ts │ │ │ │ ├── skillSettings.test.ts │ │ │ │ ├── skillSettings.ts │ │ │ │ ├── skillUtils.test.ts │ │ │ │ ├── skillUtils.ts │ │ │ │ ├── spawnWrapper.ts │ │ │ │ ├── startupWarnings.test.ts │ │ │ │ ├── startupWarnings.ts │ │ │ │ ├── terminalNotifications.test.ts │ │ │ │ ├── terminalNotifications.ts │ │ │ │ ├── terminalTheme.ts │ │ │ │ ├── tierUtils.test.ts │ │ │ │ ├── tierUtils.ts │ │ │ │ ├── toolOutputCleanup.test.ts │ │ │ │ ├── updateEventEmitter.test.ts │ │ │ │ ├── updateEventEmitter.ts │ │ │ │ ├── userStartupWarnings.test.ts │ │ │ │ ├── userStartupWarnings.ts │ │ │ │ ├── windowTitle.test.ts │ │ │ │ └── windowTitle.ts │ │ │ ├── validateNonInterActiveAuth.test.ts │ │ │ └── validateNonInterActiveAuth.ts │ │ ├── test-setup.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── core/ │ │ ├── GEMINI.md │ │ ├── index.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── bundle-browser-mcp.mjs │ │ │ └── compile-windows-sandbox.js │ │ ├── src/ │ │ │ ├── __mocks__/ │ │ │ │ └── fs/ │ │ │ │ └── promises.ts │ │ │ ├── agent/ │ │ │ │ ├── content-utils.test.ts │ │ │ │ ├── content-utils.ts │ │ │ │ ├── mock.test.ts │ │ │ │ ├── mock.ts │ │ │ │ └── types.ts │ │ │ ├── agents/ │ │ │ │ ├── a2a-client-manager.test.ts │ │ │ │ ├── a2a-client-manager.ts │ │ │ │ ├── a2a-errors.test.ts │ │ │ │ ├── a2a-errors.ts │ │ │ │ ├── a2aUtils.test.ts │ │ │ │ ├── a2aUtils.ts │ │ │ │ ├── acknowledgedAgents.test.ts │ │ │ │ ├── acknowledgedAgents.ts │ │ │ │ ├── agent-scheduler.test.ts │ │ │ │ ├── agent-scheduler.ts │ │ │ │ ├── agentLoader.test.ts │ │ │ │ ├── agentLoader.ts │ │ │ │ ├── auth-provider/ │ │ │ │ │ ├── api-key-provider.test.ts │ │ │ │ │ ├── api-key-provider.ts │ │ │ │ │ ├── base-provider.test.ts │ │ │ │ │ ├── base-provider.ts │ │ │ │ │ ├── factory.test.ts │ │ │ │ │ ├── factory.ts │ │ │ │ │ ├── google-credentials-provider.test.ts │ │ │ │ │ ├── google-credentials-provider.ts │ │ │ │ │ ├── http-provider.test.ts │ │ │ │ │ ├── http-provider.ts │ │ │ │ │ ├── oauth2-provider.test.ts │ │ │ │ │ ├── oauth2-provider.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── value-resolver.test.ts │ │ │ │ │ └── value-resolver.ts │ │ │ │ ├── browser/ │ │ │ │ │ ├── analyzeScreenshot.test.ts │ │ │ │ │ ├── analyzeScreenshot.ts │ │ │ │ │ ├── automationOverlay.ts │ │ │ │ │ ├── browser-tools-manifest.json │ │ │ │ │ ├── browserAgentDefinition.ts │ │ │ │ │ ├── browserAgentFactory.test.ts │ │ │ │ │ ├── browserAgentFactory.ts │ │ │ │ │ ├── browserAgentInvocation.test.ts │ │ │ │ │ ├── browserAgentInvocation.ts │ │ │ │ │ ├── browserManager.test.ts │ │ │ │ │ ├── browserManager.ts │ │ │ │ │ ├── inputBlocker.test.ts │ │ │ │ │ ├── inputBlocker.ts │ │ │ │ │ ├── mcpToolWrapper.test.ts │ │ │ │ │ ├── mcpToolWrapper.ts │ │ │ │ │ ├── mcpToolWrapperConfirmation.test.ts │ │ │ │ │ └── modelAvailability.ts │ │ │ │ ├── cli-help-agent.test.ts │ │ │ │ ├── cli-help-agent.ts │ │ │ │ ├── codebase-investigator.test.ts │ │ │ │ ├── codebase-investigator.ts │ │ │ │ ├── generalist-agent.test.ts │ │ │ │ ├── generalist-agent.ts │ │ │ │ ├── local-executor.test.ts │ │ │ │ ├── local-executor.ts │ │ │ │ ├── local-invocation.test.ts │ │ │ │ ├── local-invocation.ts │ │ │ │ ├── memory-manager-agent.test.ts │ │ │ │ ├── memory-manager-agent.ts │ │ │ │ ├── registry.test.ts │ │ │ │ ├── registry.ts │ │ │ │ ├── registry_acknowledgement.test.ts │ │ │ │ ├── remote-invocation.test.ts │ │ │ │ ├── remote-invocation.ts │ │ │ │ ├── subagent-tool-wrapper.test.ts │ │ │ │ ├── subagent-tool-wrapper.ts │ │ │ │ ├── subagent-tool.test.ts │ │ │ │ ├── subagent-tool.ts │ │ │ │ ├── types.ts │ │ │ │ ├── utils.test.ts │ │ │ │ └── utils.ts │ │ │ ├── availability/ │ │ │ │ ├── errorClassification.ts │ │ │ │ ├── fallbackIntegration.test.ts │ │ │ │ ├── modelAvailabilityService.test.ts │ │ │ │ ├── modelAvailabilityService.ts │ │ │ │ ├── modelPolicy.ts │ │ │ │ ├── policyCatalog.test.ts │ │ │ │ ├── policyCatalog.ts │ │ │ │ ├── policyHelpers.test.ts │ │ │ │ ├── policyHelpers.ts │ │ │ │ └── testUtils.ts │ │ │ ├── billing/ │ │ │ │ ├── billing.test.ts │ │ │ │ ├── billing.ts │ │ │ │ └── index.ts │ │ │ ├── code_assist/ │ │ │ │ ├── admin/ │ │ │ │ │ ├── admin_controls.test.ts │ │ │ │ │ ├── admin_controls.ts │ │ │ │ │ ├── mcpUtils.test.ts │ │ │ │ │ └── mcpUtils.ts │ │ │ │ ├── codeAssist.test.ts │ │ │ │ ├── codeAssist.ts │ │ │ │ ├── converter.test.ts │ │ │ │ ├── converter.ts │ │ │ │ ├── experiments/ │ │ │ │ │ ├── client_metadata.test.ts │ │ │ │ │ ├── client_metadata.ts │ │ │ │ │ ├── experiments.test.ts │ │ │ │ │ ├── experiments.ts │ │ │ │ │ ├── experiments_local.test.ts │ │ │ │ │ ├── flagNames.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── oauth-credential-storage.test.ts │ │ │ │ ├── oauth-credential-storage.ts │ │ │ │ ├── oauth2.test.ts │ │ │ │ ├── oauth2.ts │ │ │ │ ├── server.test.ts │ │ │ │ ├── server.ts │ │ │ │ ├── setup.test.ts │ │ │ │ ├── setup.ts │ │ │ │ ├── telemetry.test.ts │ │ │ │ ├── telemetry.ts │ │ │ │ └── types.ts │ │ │ ├── commands/ │ │ │ │ ├── extensions.test.ts │ │ │ │ ├── extensions.ts │ │ │ │ ├── init.test.ts │ │ │ │ ├── init.ts │ │ │ │ ├── memory.test.ts │ │ │ │ ├── memory.ts │ │ │ │ ├── restore.test.ts │ │ │ │ ├── restore.ts │ │ │ │ └── types.ts │ │ │ ├── config/ │ │ │ │ ├── agent-loop-context.ts │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── defaultModelConfigs.ts │ │ │ │ ├── extensions/ │ │ │ │ │ ├── integrity.test.ts │ │ │ │ │ ├── integrity.ts │ │ │ │ │ └── integrityTypes.ts │ │ │ │ ├── flashFallback.test.ts │ │ │ │ ├── injectionService.test.ts │ │ │ │ ├── injectionService.ts │ │ │ │ ├── memory.test.ts │ │ │ │ ├── memory.ts │ │ │ │ ├── models.test.ts │ │ │ │ ├── models.ts │ │ │ │ ├── path-validation.test.ts │ │ │ │ ├── projectRegistry.test.ts │ │ │ │ ├── projectRegistry.ts │ │ │ │ ├── sandbox-integration.test.ts │ │ │ │ ├── storage.test.ts │ │ │ │ ├── storage.ts │ │ │ │ ├── storageMigration.test.ts │ │ │ │ ├── storageMigration.ts │ │ │ │ └── trackerFeatureFlag.test.ts │ │ │ ├── confirmation-bus/ │ │ │ │ ├── index.ts │ │ │ │ ├── message-bus.test.ts │ │ │ │ ├── message-bus.ts │ │ │ │ └── types.ts │ │ │ ├── core/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── prompts.test.ts.snap │ │ │ │ ├── apiKeyCredentialStorage.test.ts │ │ │ │ ├── apiKeyCredentialStorage.ts │ │ │ │ ├── baseLlmClient.test.ts │ │ │ │ ├── baseLlmClient.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── client.ts │ │ │ │ ├── contentGenerator.test.ts │ │ │ │ ├── contentGenerator.ts │ │ │ │ ├── coreToolHookTriggers.test.ts │ │ │ │ ├── coreToolHookTriggers.ts │ │ │ │ ├── coreToolScheduler.test.ts │ │ │ │ ├── coreToolScheduler.ts │ │ │ │ ├── fakeContentGenerator.test.ts │ │ │ │ ├── fakeContentGenerator.ts │ │ │ │ ├── geminiChat.test.ts │ │ │ │ ├── geminiChat.ts │ │ │ │ ├── geminiChat_network_retry.test.ts │ │ │ │ ├── geminiRequest.ts │ │ │ │ ├── localLiteRtLmClient.test.ts │ │ │ │ ├── localLiteRtLmClient.ts │ │ │ │ ├── logger.test.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── loggingContentGenerator.test.ts │ │ │ │ ├── loggingContentGenerator.ts │ │ │ │ ├── prompts-substitution.test.ts │ │ │ │ ├── prompts.test.ts │ │ │ │ ├── prompts.ts │ │ │ │ ├── recordingContentGenerator.test.ts │ │ │ │ ├── recordingContentGenerator.ts │ │ │ │ ├── tokenLimits.test.ts │ │ │ │ ├── tokenLimits.ts │ │ │ │ ├── turn.test.ts │ │ │ │ └── turn.ts │ │ │ ├── fallback/ │ │ │ │ ├── handler.test.ts │ │ │ │ ├── handler.ts │ │ │ │ └── types.ts │ │ │ ├── hooks/ │ │ │ │ ├── hookAggregator.test.ts │ │ │ │ ├── hookAggregator.ts │ │ │ │ ├── hookEventHandler.test.ts │ │ │ │ ├── hookEventHandler.ts │ │ │ │ ├── hookPlanner.test.ts │ │ │ │ ├── hookPlanner.ts │ │ │ │ ├── hookRegistry.test.ts │ │ │ │ ├── hookRegistry.ts │ │ │ │ ├── hookRunner.test.ts │ │ │ │ ├── hookRunner.ts │ │ │ │ ├── hookSystem.test.ts │ │ │ │ ├── hookSystem.ts │ │ │ │ ├── hookTranslator.test.ts │ │ │ │ ├── hookTranslator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── runtimeHooks.test.ts │ │ │ │ ├── trustedHooks.test.ts │ │ │ │ ├── trustedHooks.ts │ │ │ │ ├── types.test.ts │ │ │ │ └── types.ts │ │ │ ├── ide/ │ │ │ │ ├── constants.ts │ │ │ │ ├── detect-ide.test.ts │ │ │ │ ├── detect-ide.ts │ │ │ │ ├── ide-client.test.ts │ │ │ │ ├── ide-client.ts │ │ │ │ ├── ide-connection-utils.test.ts │ │ │ │ ├── ide-connection-utils.ts │ │ │ │ ├── ide-installer.test.ts │ │ │ │ ├── ide-installer.ts │ │ │ │ ├── ideContext.test.ts │ │ │ │ ├── ideContext.ts │ │ │ │ ├── process-utils.test.ts │ │ │ │ ├── process-utils.ts │ │ │ │ └── types.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── mcp/ │ │ │ │ ├── auth-provider.ts │ │ │ │ ├── google-auth-provider.test.ts │ │ │ │ ├── google-auth-provider.ts │ │ │ │ ├── mcp-oauth-provider.test.ts │ │ │ │ ├── mcp-oauth-provider.ts │ │ │ │ ├── oauth-provider.test.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── oauth-token-storage.test.ts │ │ │ │ ├── oauth-token-storage.ts │ │ │ │ ├── oauth-utils.test.ts │ │ │ │ ├── oauth-utils.ts │ │ │ │ ├── sa-impersonation-provider.test.ts │ │ │ │ ├── sa-impersonation-provider.ts │ │ │ │ └── token-storage/ │ │ │ │ ├── base-token-storage.test.ts │ │ │ │ ├── base-token-storage.ts │ │ │ │ ├── hybrid-token-storage.test.ts │ │ │ │ ├── hybrid-token-storage.ts │ │ │ │ ├── index.ts │ │ │ │ ├── keychain-token-storage.test.ts │ │ │ │ ├── keychain-token-storage.ts │ │ │ │ └── types.ts │ │ │ ├── mocks/ │ │ │ │ └── msw.ts │ │ │ ├── output/ │ │ │ │ ├── json-formatter.test.ts │ │ │ │ ├── json-formatter.ts │ │ │ │ ├── stream-json-formatter.test.ts │ │ │ │ ├── stream-json-formatter.ts │ │ │ │ └── types.ts │ │ │ ├── policy/ │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integrity.test.ts │ │ │ │ ├── integrity.ts │ │ │ │ ├── memory-manager-policy.test.ts │ │ │ │ ├── persistence.test.ts │ │ │ │ ├── policies/ │ │ │ │ │ ├── conseca.toml │ │ │ │ │ ├── discovered.toml │ │ │ │ │ ├── memory-manager.toml │ │ │ │ │ ├── plan.toml │ │ │ │ │ ├── read-only.toml │ │ │ │ │ ├── tracker.toml │ │ │ │ │ ├── write.toml │ │ │ │ │ └── yolo.toml │ │ │ │ ├── policy-engine.test.ts │ │ │ │ ├── policy-engine.ts │ │ │ │ ├── policy-updater.test.ts │ │ │ │ ├── shell-safety.test.ts │ │ │ │ ├── stable-stringify.ts │ │ │ │ ├── toml-loader.test.ts │ │ │ │ ├── toml-loader.ts │ │ │ │ ├── types.ts │ │ │ │ ├── utils.test.ts │ │ │ │ ├── utils.ts │ │ │ │ └── workspace-policy.test.ts │ │ │ ├── prompts/ │ │ │ │ ├── mcp-prompts.test.ts │ │ │ │ ├── mcp-prompts.ts │ │ │ │ ├── prompt-registry.test.ts │ │ │ │ ├── prompt-registry.ts │ │ │ │ ├── promptProvider.test.ts │ │ │ │ ├── promptProvider.ts │ │ │ │ ├── snippets-memory-manager.test.ts │ │ │ │ ├── snippets.legacy.ts │ │ │ │ ├── snippets.ts │ │ │ │ ├── utils.test.ts │ │ │ │ └── utils.ts │ │ │ ├── resources/ │ │ │ │ ├── resource-registry.test.ts │ │ │ │ └── resource-registry.ts │ │ │ ├── routing/ │ │ │ │ ├── modelRouterService.test.ts │ │ │ │ ├── modelRouterService.ts │ │ │ │ ├── routingStrategy.ts │ │ │ │ └── strategies/ │ │ │ │ ├── approvalModeStrategy.test.ts │ │ │ │ ├── approvalModeStrategy.ts │ │ │ │ ├── classifierStrategy.test.ts │ │ │ │ ├── classifierStrategy.ts │ │ │ │ ├── compositeStrategy.test.ts │ │ │ │ ├── compositeStrategy.ts │ │ │ │ ├── defaultStrategy.test.ts │ │ │ │ ├── defaultStrategy.ts │ │ │ │ ├── fallbackStrategy.test.ts │ │ │ │ ├── fallbackStrategy.ts │ │ │ │ ├── gemmaClassifierStrategy.test.ts │ │ │ │ ├── gemmaClassifierStrategy.ts │ │ │ │ ├── numericalClassifierStrategy.test.ts │ │ │ │ ├── numericalClassifierStrategy.ts │ │ │ │ ├── overrideStrategy.test.ts │ │ │ │ └── overrideStrategy.ts │ │ │ ├── safety/ │ │ │ │ ├── built-in.test.ts │ │ │ │ ├── built-in.ts │ │ │ │ ├── checker-runner.test.ts │ │ │ │ ├── checker-runner.ts │ │ │ │ ├── conseca/ │ │ │ │ │ ├── conseca.test.ts │ │ │ │ │ ├── conseca.ts │ │ │ │ │ ├── integration.test.ts │ │ │ │ │ ├── policy-enforcer.test.ts │ │ │ │ │ ├── policy-enforcer.ts │ │ │ │ │ ├── policy-generator.test.ts │ │ │ │ │ ├── policy-generator.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── context-builder.test.ts │ │ │ │ ├── context-builder.ts │ │ │ │ ├── protocol.ts │ │ │ │ ├── registry.test.ts │ │ │ │ └── registry.ts │ │ │ ├── sandbox/ │ │ │ │ ├── linux/ │ │ │ │ │ ├── LinuxSandboxManager.test.ts │ │ │ │ │ └── LinuxSandboxManager.ts │ │ │ │ └── macos/ │ │ │ │ ├── MacOsSandboxManager.integration.test.ts │ │ │ │ ├── MacOsSandboxManager.test.ts │ │ │ │ ├── MacOsSandboxManager.ts │ │ │ │ ├── baseProfile.ts │ │ │ │ ├── seatbeltArgsBuilder.test.ts │ │ │ │ └── seatbeltArgsBuilder.ts │ │ │ ├── scheduler/ │ │ │ │ ├── confirmation.test.ts │ │ │ │ ├── confirmation.ts │ │ │ │ ├── policy.test.ts │ │ │ │ ├── policy.ts │ │ │ │ ├── scheduler.test.ts │ │ │ │ ├── scheduler.ts │ │ │ │ ├── scheduler_parallel.test.ts │ │ │ │ ├── scheduler_waiting_callback.test.ts │ │ │ │ ├── state-manager.test.ts │ │ │ │ ├── state-manager.ts │ │ │ │ ├── tool-executor.test.ts │ │ │ │ ├── tool-executor.ts │ │ │ │ ├── tool-modifier.test.ts │ │ │ │ ├── tool-modifier.ts │ │ │ │ └── types.ts │ │ │ ├── services/ │ │ │ │ ├── FolderTrustDiscoveryService.test.ts │ │ │ │ ├── FolderTrustDiscoveryService.ts │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── toolOutputMaskingService.test.ts.snap │ │ │ │ ├── chatCompressionService.test.ts │ │ │ │ ├── chatCompressionService.ts │ │ │ │ ├── chatRecordingService.test.ts │ │ │ │ ├── chatRecordingService.ts │ │ │ │ ├── contextManager.test.ts │ │ │ │ ├── contextManager.ts │ │ │ │ ├── environmentSanitization.test.ts │ │ │ │ ├── environmentSanitization.ts │ │ │ │ ├── executionLifecycleService.test.ts │ │ │ │ ├── executionLifecycleService.ts │ │ │ │ ├── fileDiscoveryService.test.ts │ │ │ │ ├── fileDiscoveryService.ts │ │ │ │ ├── fileKeychain.ts │ │ │ │ ├── fileSystemService.test.ts │ │ │ │ ├── fileSystemService.ts │ │ │ │ ├── gitService.test.ts │ │ │ │ ├── gitService.ts │ │ │ │ ├── keychainService.test.ts │ │ │ │ ├── keychainService.ts │ │ │ │ ├── keychainTypes.ts │ │ │ │ ├── loopDetectionService.test.ts │ │ │ │ ├── loopDetectionService.ts │ │ │ │ ├── modelConfig.golden.test.ts │ │ │ │ ├── modelConfig.integration.test.ts │ │ │ │ ├── modelConfigService.test.ts │ │ │ │ ├── modelConfigService.ts │ │ │ │ ├── modelConfigServiceTestUtils.ts │ │ │ │ ├── sandboxManager.test.ts │ │ │ │ ├── sandboxManager.ts │ │ │ │ ├── sandboxManagerFactory.ts │ │ │ │ ├── sandboxedFileSystemService.test.ts │ │ │ │ ├── sandboxedFileSystemService.ts │ │ │ │ ├── scripts/ │ │ │ │ │ └── GeminiSandbox.cs │ │ │ │ ├── sessionSummaryService.test.ts │ │ │ │ ├── sessionSummaryService.ts │ │ │ │ ├── sessionSummaryUtils.test.ts │ │ │ │ ├── sessionSummaryUtils.ts │ │ │ │ ├── shellExecutionService.test.ts │ │ │ │ ├── shellExecutionService.ts │ │ │ │ ├── test-data/ │ │ │ │ │ ├── resolved-aliases-retry.golden.json │ │ │ │ │ └── resolved-aliases.golden.json │ │ │ │ ├── toolOutputMaskingService.test.ts │ │ │ │ ├── toolOutputMaskingService.ts │ │ │ │ ├── trackerService.test.ts │ │ │ │ ├── trackerService.ts │ │ │ │ ├── trackerTypes.ts │ │ │ │ ├── windowsSandboxManager.test.ts │ │ │ │ └── windowsSandboxManager.ts │ │ │ ├── skills/ │ │ │ │ ├── builtin/ │ │ │ │ │ └── skill-creator/ │ │ │ │ │ ├── SKILL.md │ │ │ │ │ └── scripts/ │ │ │ │ │ ├── init_skill.cjs │ │ │ │ │ ├── package_skill.cjs │ │ │ │ │ └── validate_skill.cjs │ │ │ │ ├── skillLoader.test.ts │ │ │ │ ├── skillLoader.ts │ │ │ │ ├── skillManager.test.ts │ │ │ │ ├── skillManager.ts │ │ │ │ └── skillManagerAlias.test.ts │ │ │ ├── telemetry/ │ │ │ │ ├── activity-detector.test.ts │ │ │ │ ├── activity-detector.ts │ │ │ │ ├── activity-monitor.test.ts │ │ │ │ ├── activity-monitor.ts │ │ │ │ ├── activity-types.ts │ │ │ │ ├── billingEvents.test.ts │ │ │ │ ├── billingEvents.ts │ │ │ │ ├── clearcut-logger/ │ │ │ │ │ ├── clearcut-logger.test.ts │ │ │ │ │ ├── clearcut-logger.ts │ │ │ │ │ └── event-metadata-key.ts │ │ │ │ ├── config.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── conseca-logger.test.ts │ │ │ │ ├── conseca-logger.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── file-exporters.test.ts │ │ │ │ ├── file-exporters.ts │ │ │ │ ├── gcp-exporters.test.ts │ │ │ │ ├── gcp-exporters.ts │ │ │ │ ├── high-water-mark-tracker.test.ts │ │ │ │ ├── high-water-mark-tracker.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integration.test.circular.ts │ │ │ │ ├── llmRole.ts │ │ │ │ ├── loggers.test.circular.ts │ │ │ │ ├── loggers.test.ts │ │ │ │ ├── loggers.ts │ │ │ │ ├── memory-monitor.test.ts │ │ │ │ ├── memory-monitor.ts │ │ │ │ ├── metrics.test.ts │ │ │ │ ├── metrics.ts │ │ │ │ ├── rate-limiter.test.ts │ │ │ │ ├── rate-limiter.ts │ │ │ │ ├── sanitize.test.ts │ │ │ │ ├── sanitize.ts │ │ │ │ ├── sdk.test.ts │ │ │ │ ├── sdk.ts │ │ │ │ ├── semantic.test.ts │ │ │ │ ├── semantic.truncation.test.ts │ │ │ │ ├── semantic.ts │ │ │ │ ├── startupProfiler.test.ts │ │ │ │ ├── startupProfiler.ts │ │ │ │ ├── telemetry-utils.test.ts │ │ │ │ ├── telemetry-utils.ts │ │ │ │ ├── telemetry.test.ts │ │ │ │ ├── telemetryAttributes.ts │ │ │ │ ├── tool-call-decision.ts │ │ │ │ ├── trace.test.ts │ │ │ │ ├── trace.ts │ │ │ │ ├── types.ts │ │ │ │ ├── uiTelemetry.test.ts │ │ │ │ └── uiTelemetry.ts │ │ │ ├── test-utils/ │ │ │ │ ├── config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mock-message-bus.ts │ │ │ │ ├── mock-tool.ts │ │ │ │ └── mockWorkspaceContext.ts │ │ │ ├── tools/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── read-file.test.ts.snap │ │ │ │ │ └── shell.test.ts.snap │ │ │ │ ├── activate-skill.test.ts │ │ │ │ ├── activate-skill.ts │ │ │ │ ├── ask-user.test.ts │ │ │ │ ├── ask-user.ts │ │ │ │ ├── base-tool-invocation.test.ts │ │ │ │ ├── confirmation-policy.test.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── definitions/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── coreToolsModelSnapshots.test.ts.snap │ │ │ │ │ ├── base-declarations.ts │ │ │ │ │ ├── coreTools.ts │ │ │ │ │ ├── coreToolsModelSnapshots.test.ts │ │ │ │ │ ├── dynamic-declaration-helpers.ts │ │ │ │ │ ├── model-family-sets/ │ │ │ │ │ │ ├── default-legacy.ts │ │ │ │ │ │ └── gemini-3.ts │ │ │ │ │ ├── modelFamilyService.ts │ │ │ │ │ ├── resolver.test.ts │ │ │ │ │ ├── resolver.ts │ │ │ │ │ ├── trackerTools.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── diff-utils.test.ts │ │ │ │ ├── diff-utils.ts │ │ │ │ ├── diffOptions.test.ts │ │ │ │ ├── diffOptions.ts │ │ │ │ ├── edit.test.ts │ │ │ │ ├── edit.ts │ │ │ │ ├── enter-plan-mode.test.ts │ │ │ │ ├── enter-plan-mode.ts │ │ │ │ ├── exit-plan-mode.test.ts │ │ │ │ ├── exit-plan-mode.ts │ │ │ │ ├── get-internal-docs.test.ts │ │ │ │ ├── get-internal-docs.ts │ │ │ │ ├── glob.test.ts │ │ │ │ ├── glob.ts │ │ │ │ ├── grep-utils.ts │ │ │ │ ├── grep.test.ts │ │ │ │ ├── grep.ts │ │ │ │ ├── jit-context.test.ts │ │ │ │ ├── jit-context.ts │ │ │ │ ├── line-endings.test.ts │ │ │ │ ├── ls.test.ts │ │ │ │ ├── ls.ts │ │ │ │ ├── mcp-client-manager.test.ts │ │ │ │ ├── mcp-client-manager.ts │ │ │ │ ├── mcp-client.test.ts │ │ │ │ ├── mcp-client.ts │ │ │ │ ├── mcp-tool.test.ts │ │ │ │ ├── mcp-tool.ts │ │ │ │ ├── memoryTool.test.ts │ │ │ │ ├── memoryTool.ts │ │ │ │ ├── message-bus-integration.test.ts │ │ │ │ ├── modifiable-tool.test.ts │ │ │ │ ├── modifiable-tool.ts │ │ │ │ ├── omissionPlaceholderDetector.test.ts │ │ │ │ ├── omissionPlaceholderDetector.ts │ │ │ │ ├── read-file.test.ts │ │ │ │ ├── read-file.ts │ │ │ │ ├── read-many-files.test.ts │ │ │ │ ├── read-many-files.ts │ │ │ │ ├── ripGrep.test.ts │ │ │ │ ├── ripGrep.ts │ │ │ │ ├── shell.test.ts │ │ │ │ ├── shell.ts │ │ │ │ ├── tool-error.ts │ │ │ │ ├── tool-names.test.ts │ │ │ │ ├── tool-names.ts │ │ │ │ ├── tool-registry.test.ts │ │ │ │ ├── tool-registry.ts │ │ │ │ ├── tools.test.ts │ │ │ │ ├── tools.ts │ │ │ │ ├── trackerTools.test.ts │ │ │ │ ├── trackerTools.ts │ │ │ │ ├── web-fetch.test.ts │ │ │ │ ├── web-fetch.ts │ │ │ │ ├── web-search.test.ts │ │ │ │ ├── web-search.ts │ │ │ │ ├── write-file.test.ts │ │ │ │ ├── write-file.ts │ │ │ │ ├── write-todos.test.ts │ │ │ │ ├── write-todos.ts │ │ │ │ ├── xcode-mcp-fix-transport.test.ts │ │ │ │ └── xcode-mcp-fix-transport.ts │ │ │ ├── utils/ │ │ │ │ ├── __fixtures__/ │ │ │ │ │ └── dummy.wasm │ │ │ │ ├── apiConversionUtils.test.ts │ │ │ │ ├── apiConversionUtils.ts │ │ │ │ ├── approvalModeUtils.test.ts │ │ │ │ ├── approvalModeUtils.ts │ │ │ │ ├── authConsent.test.ts │ │ │ │ ├── authConsent.ts │ │ │ │ ├── bfsFileSearch.test.ts │ │ │ │ ├── bfsFileSearch.ts │ │ │ │ ├── browser.ts │ │ │ │ ├── browserConsent.test.ts │ │ │ │ ├── browserConsent.ts │ │ │ │ ├── cache.test.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── channel.test.ts │ │ │ │ ├── channel.ts │ │ │ │ ├── checkpointUtils.test.ts │ │ │ │ ├── checkpointUtils.ts │ │ │ │ ├── checks.test.ts │ │ │ │ ├── checks.ts │ │ │ │ ├── compatibility.test.ts │ │ │ │ ├── compatibility.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── customHeaderUtils.test.ts │ │ │ │ ├── customHeaderUtils.ts │ │ │ │ ├── deadlineTimer.test.ts │ │ │ │ ├── deadlineTimer.ts │ │ │ │ ├── debugLogger.test.ts │ │ │ │ ├── debugLogger.ts │ │ │ │ ├── delay.test.ts │ │ │ │ ├── delay.ts │ │ │ │ ├── editCorrector.test.ts │ │ │ │ ├── editCorrector.ts │ │ │ │ ├── editor.test.ts │ │ │ │ ├── editor.ts │ │ │ │ ├── envExpansion.test.ts │ │ │ │ ├── envExpansion.ts │ │ │ │ ├── environmentContext.test.ts │ │ │ │ ├── environmentContext.ts │ │ │ │ ├── errorParsing.test.ts │ │ │ │ ├── errorParsing.ts │ │ │ │ ├── errorReporting.test.ts │ │ │ │ ├── errorReporting.ts │ │ │ │ ├── errors.test.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── errors_timeout.test.ts │ │ │ │ ├── events.test.ts │ │ │ │ ├── events.ts │ │ │ │ ├── exitCodes.ts │ │ │ │ ├── extensionLoader.test.ts │ │ │ │ ├── extensionLoader.ts │ │ │ │ ├── fastAckHelper.test.ts │ │ │ │ ├── fastAckHelper.ts │ │ │ │ ├── fetch.test.ts │ │ │ │ ├── fetch.ts │ │ │ │ ├── fileDiffUtils.test.ts │ │ │ │ ├── fileDiffUtils.ts │ │ │ │ ├── fileUtils.test.ts │ │ │ │ ├── fileUtils.ts │ │ │ │ ├── filesearch/ │ │ │ │ │ ├── crawlCache.test.ts │ │ │ │ │ ├── crawlCache.ts │ │ │ │ │ ├── crawler.test.ts │ │ │ │ │ ├── crawler.ts │ │ │ │ │ ├── fileSearch.test.ts │ │ │ │ │ ├── fileSearch.ts │ │ │ │ │ ├── ignore.test.ts │ │ │ │ │ ├── ignore.ts │ │ │ │ │ ├── result-cache.test.ts │ │ │ │ │ └── result-cache.ts │ │ │ │ ├── flashFallback.test.ts │ │ │ │ ├── formatters.test.ts │ │ │ │ ├── formatters.ts │ │ │ │ ├── fsErrorMessages.test.ts │ │ │ │ ├── fsErrorMessages.ts │ │ │ │ ├── generateContentResponseUtilities.test.ts │ │ │ │ ├── generateContentResponseUtilities.ts │ │ │ │ ├── getFolderStructure.test.ts │ │ │ │ ├── getFolderStructure.ts │ │ │ │ ├── getPty.ts │ │ │ │ ├── gitIgnoreParser.test.ts │ │ │ │ ├── gitIgnoreParser.ts │ │ │ │ ├── gitUtils.ts │ │ │ │ ├── googleErrors.test.ts │ │ │ │ ├── googleErrors.ts │ │ │ │ ├── googleQuotaErrors.test.ts │ │ │ │ ├── googleQuotaErrors.ts │ │ │ │ ├── headless.test.ts │ │ │ │ ├── headless.ts │ │ │ │ ├── httpErrors.ts │ │ │ │ ├── ignoreFileParser.test.ts │ │ │ │ ├── ignoreFileParser.ts │ │ │ │ ├── ignorePatterns.test.ts │ │ │ │ ├── ignorePatterns.ts │ │ │ │ ├── installationManager.test.ts │ │ │ │ ├── installationManager.ts │ │ │ │ ├── language-detection.test.ts │ │ │ │ ├── language-detection.ts │ │ │ │ ├── llm-edit-fixer.test.ts │ │ │ │ ├── llm-edit-fixer.ts │ │ │ │ ├── markdownUtils.test.ts │ │ │ │ ├── markdownUtils.ts │ │ │ │ ├── memoryDiscovery.test.ts │ │ │ │ ├── memoryDiscovery.ts │ │ │ │ ├── memoryImportProcessor.test.ts │ │ │ │ ├── memoryImportProcessor.ts │ │ │ │ ├── messageInspectors.ts │ │ │ │ ├── nextSpeakerChecker.test.ts │ │ │ │ ├── nextSpeakerChecker.ts │ │ │ │ ├── oauth-flow.test.ts │ │ │ │ ├── oauth-flow.ts │ │ │ │ ├── package.test.ts │ │ │ │ ├── package.ts │ │ │ │ ├── partUtils.test.ts │ │ │ │ ├── partUtils.ts │ │ │ │ ├── pathCorrector.test.ts │ │ │ │ ├── pathCorrector.ts │ │ │ │ ├── pathReader.test.ts │ │ │ │ ├── pathReader.ts │ │ │ │ ├── paths.test.ts │ │ │ │ ├── paths.ts │ │ │ │ ├── planUtils.test.ts │ │ │ │ ├── planUtils.ts │ │ │ │ ├── process-utils.test.ts │ │ │ │ ├── process-utils.ts │ │ │ │ ├── promptIdContext.ts │ │ │ │ ├── quotaErrorDetection.ts │ │ │ │ ├── retry.test.ts │ │ │ │ ├── retry.ts │ │ │ │ ├── safeJsonStringify.test.ts │ │ │ │ ├── safeJsonStringify.ts │ │ │ │ ├── schemaValidator.test.ts │ │ │ │ ├── schemaValidator.ts │ │ │ │ ├── secure-browser-launcher.test.ts │ │ │ │ ├── secure-browser-launcher.ts │ │ │ │ ├── security.test.ts │ │ │ │ ├── security.ts │ │ │ │ ├── session.ts │ │ │ │ ├── sessionUtils.test.ts │ │ │ │ ├── sessionUtils.ts │ │ │ │ ├── shell-utils.integration.test.ts │ │ │ │ ├── shell-utils.test.ts │ │ │ │ ├── shell-utils.ts │ │ │ │ ├── stdio.test.ts │ │ │ │ ├── stdio.ts │ │ │ │ ├── summarizer.test.ts │ │ │ │ ├── summarizer.ts │ │ │ │ ├── surface.ts │ │ │ │ ├── systemEncoding.test.ts │ │ │ │ ├── systemEncoding.ts │ │ │ │ ├── terminal.ts │ │ │ │ ├── terminalSerializer.test.ts │ │ │ │ ├── terminalSerializer.ts │ │ │ │ ├── testUtils.ts │ │ │ │ ├── textUtils.test.ts │ │ │ │ ├── textUtils.ts │ │ │ │ ├── thoughtUtils.test.ts │ │ │ │ ├── thoughtUtils.ts │ │ │ │ ├── tokenCalculation.test.ts │ │ │ │ ├── tokenCalculation.ts │ │ │ │ ├── tool-utils.test.ts │ │ │ │ ├── tool-utils.ts │ │ │ │ ├── toolCallContext.test.ts │ │ │ │ ├── toolCallContext.ts │ │ │ │ ├── userAccountManager.test.ts │ │ │ │ ├── userAccountManager.ts │ │ │ │ ├── version.test.ts │ │ │ │ ├── version.ts │ │ │ │ ├── workspaceContext.test.ts │ │ │ │ └── workspaceContext.ts │ │ │ └── voice/ │ │ │ ├── responseFormatter.test.ts │ │ │ └── responseFormatter.ts │ │ ├── test-setup.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── devtools/ │ │ ├── GEMINI.md │ │ ├── client/ │ │ │ ├── index.html │ │ │ └── src/ │ │ │ ├── App.tsx │ │ │ ├── hooks.ts │ │ │ └── main.tsx │ │ ├── esbuild.client.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── sdk/ │ │ ├── GEMINI.md │ │ ├── README.md │ │ ├── SDK_DESIGN.md │ │ ├── examples/ │ │ │ ├── session-context.ts │ │ │ └── simple.ts │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agent.integration.test.ts │ │ │ ├── agent.ts │ │ │ ├── fs.ts │ │ │ ├── index.ts │ │ │ ├── session.ts │ │ │ ├── shell.ts │ │ │ ├── skills.integration.test.ts │ │ │ ├── skills.ts │ │ │ ├── tool.integration.test.ts │ │ │ ├── tool.test.ts │ │ │ ├── tool.ts │ │ │ └── types.ts │ │ ├── test-data/ │ │ │ ├── agent-async-instructions.json │ │ │ ├── agent-dynamic-instructions.json │ │ │ ├── agent-resume-session.json │ │ │ ├── agent-static-instructions.json │ │ │ ├── skill-dir-success.json │ │ │ ├── skill-root-success.json │ │ │ ├── skills/ │ │ │ │ └── pirate-skill/ │ │ │ │ └── SKILL.md │ │ │ ├── tool-catchall-error.json │ │ │ ├── tool-error-recovery.json │ │ │ └── tool-success.json │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── test-utils/ │ │ ├── GEMINI.md │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── file-system-test-helpers.ts │ │ │ ├── index.ts │ │ │ ├── mock-utils.ts │ │ │ └── test-rig.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── vscode-ide-companion/ │ ├── .vscode/ │ │ ├── launch.json │ │ └── tasks.json │ ├── .vscodeignore │ ├── GEMINI.md │ ├── LICENSE │ ├── NOTICES.txt │ ├── README.md │ ├── development.md │ ├── esbuild.js │ ├── package.json │ ├── scripts/ │ │ ├── check-vscode-release.js │ │ └── generate-notices.js │ ├── src/ │ │ ├── diff-manager.ts │ │ ├── extension.test.ts │ │ ├── extension.ts │ │ ├── ide-server.test.ts │ │ ├── ide-server.ts │ │ ├── open-files-manager.test.ts │ │ ├── open-files-manager.ts │ │ └── utils/ │ │ └── logger.ts │ └── tsconfig.json ├── schemas/ │ └── settings.schema.json ├── scripts/ │ ├── aggregate_evals.js │ ├── batch_triage.sh │ ├── build.js │ ├── build_binary.js │ ├── build_package.js │ ├── build_sandbox.js │ ├── build_vscode_companion.js │ ├── changed_prompt.js │ ├── check-build-status.js │ ├── check-lockfile.js │ ├── clean.js │ ├── cleanup-branches.ts │ ├── close_duplicate_issues.js │ ├── copy_bundle_assets.js │ ├── copy_files.js │ ├── create_alias.sh │ ├── deflake.js │ ├── entitlements.plist │ ├── generate-git-commit-info.js │ ├── generate-keybindings-doc.ts │ ├── generate-settings-doc.ts │ ├── generate-settings-schema.ts │ ├── get-release-version.js │ ├── lint.js │ ├── local_telemetry.js │ ├── pre-commit.js │ ├── prepare-github-release.js │ ├── prepare-npm-release.js │ ├── prepare-package.js │ ├── relabel_issues.sh │ ├── releasing/ │ │ ├── create-patch-pr.js │ │ ├── patch-comment.js │ │ ├── patch-create-comment.js │ │ └── patch-trigger.js │ ├── review.sh │ ├── sandbox_command.js │ ├── send_gemini_request.sh │ ├── start.js │ ├── sync_project_dry_run.js │ ├── telemetry.js │ ├── telemetry_gcp.js │ ├── telemetry_genkit.js │ ├── telemetry_utils.js │ ├── test-windows-paths.js │ ├── tests/ │ │ ├── autogen.test.ts │ │ ├── generate-keybindings-doc.test.ts │ │ ├── generate-settings-doc.test.ts │ │ ├── generate-settings-schema.test.ts │ │ ├── get-release-version.test.js │ │ ├── patch-create-comment.test.js │ │ ├── telemetry_gcp.test.ts │ │ ├── test-setup.ts │ │ └── vitest.config.ts │ ├── utils/ │ │ └── autogen.ts │ └── version.js ├── sea/ │ ├── sea-launch.cjs │ └── sea-launch.test.js ├── third_party/ │ └── get-ripgrep/ │ ├── LICENSE │ ├── package.json │ └── src/ │ ├── downloadRipGrep.js │ └── index.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .allstar/branch_protection.yaml ================================================ action: 'log' ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 insert_final_newline = true end_of_line = lf indent_style = space indent_size = 2 max_line_length = 80 [Makefile] indent_style = tab indent_size = 8 ================================================ FILE: .gcp/Dockerfile.gemini-code-builder ================================================ # Use a common base image like Debian. # Using 'bookworm-slim' for a balance of size and compatibility. FROM debian:bookworm-slim # Set environment variables to prevent interactive prompts during installation ENV DEBIAN_FRONTEND=noninteractive ENV NODE_VERSION=20.12.2 ENV NODE_VERSION_MAJOR=20 ENV DOCKER_CLI_VERSION=26.1.3 ENV BUILDX_VERSION=v0.14.0 # Install dependencies for adding NodeSource repository, gcloud, and other tools # - curl: for downloading files # - gnupg: for managing GPG keys (used by NodeSource & Google Cloud SDK) # - apt-transport-https: for HTTPS apt repositories # - ca-certificates: for HTTPS apt repositories # - rsync: the rsync utility itself # - git: often useful in build environments # - python3, python3-pip, python3-venv, python3-crcmod: for gcloud SDK and some of its components # - lsb-release: for gcloud install script to identify distribution RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ gnupg \ apt-transport-https \ ca-certificates \ rsync \ git \ python3 \ python3-pip \ python3-venv \ python3-crcmod \ lsb-release \ && rm -rf /var/lib/apt/lists/* # Install Node.js and npm # We'll use the official NodeSource repository for a specific version RUN set -eux; \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ # For Node.js 20.x, it's node_20.x # Let's explicitly define the major version for clarity echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ apt-get update && \ apt-get install -y --no-install-recommends nodejs && \ npm install -g npm@latest && \ # Verify installations node -v && \ npm -v && \ rm -rf /var/lib/apt/lists/* # Install Docker CLI # Download the static binary from Docker's official source RUN set -eux; \ DOCKER_CLI_ARCH=$(dpkg --print-architecture); \ case "${DOCKER_CLI_ARCH}" in \ amd64) DOCKER_CLI_ARCH_SUFFIX="x86_64" ;; \ arm64) DOCKER_CLI_ARCH_SUFFIX="aarch64" ;; \ *) echo "Unsupported architecture: ${DOCKER_CLI_ARCH}"; exit 1 ;; \ esac; \ curl -fsSL "https://download.docker.com/linux/static/stable/${DOCKER_CLI_ARCH_SUFFIX}/docker-${DOCKER_CLI_VERSION}.tgz" -o docker.tgz && \ tar -xzf docker.tgz --strip-components=1 -C /usr/local/bin docker/docker && \ rm docker.tgz && \ # Verify installation docker --version # Install Docker Buildx plugin RUN set -eux; \ BUILDX_ARCH_DEB=$(dpkg --print-architecture); \ case "${BUILDX_ARCH_DEB}" in \ amd64) BUILDX_ARCH_SUFFIX="amd64" ;; \ arm64) BUILDX_ARCH_SUFFIX="arm64" ;; \ *) echo "Unsupported architecture for Buildx: ${BUILDX_ARCH_DEB}"; exit 1 ;; \ esac; \ mkdir -p /usr/local/lib/docker/cli-plugins && \ curl -fsSL "https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-${BUILDX_ARCH_SUFFIX}" -o /usr/local/lib/docker/cli-plugins/docker-buildx && \ chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx && \ # verify installation docker buildx version # Install Google Cloud SDK (gcloud CLI) RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && apt-get update -y && apt-get install google-cloud-cli -y # Set a working directory (optional, but good practice) WORKDIR /workspace # You can add a CMD or ENTRYPOINT if you intend to run this image directly, # but for Cloud Build, it's usually not necessary as Cloud Build steps override it. # For example: ENTRYPOINT '/bin/bash' ================================================ FILE: .gcp/release-docker.yml ================================================ steps: # Step 1: Install root dependencies (includes workspaces) - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Install Dependencies' entrypoint: 'npm' args: ['install'] # Step 2: Authenticate for Docker (so we can push images to the artifact registry) - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Authenticate docker' entrypoint: 'npm' args: ['run', 'auth'] # Step 3: Build workspace packages - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Build packages' entrypoint: 'npm' args: ['run', 'build:packages'] # Step 4: Determine Docker Image Tag - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Determine Docker Image Tag' entrypoint: 'bash' args: - '-c' - |- SHELL_TAG_NAME="$TAG_NAME" FINAL_TAG="$SHORT_SHA" # Default to SHA if [[ "$$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "Release detected." FINAL_TAG="$${SHELL_TAG_NAME#v}" else echo "Development release detected. Using commit SHA as tag." fi echo "Determined image tag: $$FINAL_TAG" echo "$$FINAL_TAG" > /workspace/image_tag.txt # Step 5: Build sandbox container image - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Build sandbox Docker image' entrypoint: 'bash' args: - '-c' - |- export GEMINI_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt) echo "Using Docker image tag for build: $$GEMINI_SANDBOX_IMAGE_TAG" npm run build:sandbox -- --output-file /workspace/final_image_uri.txt env: - 'GEMINI_SANDBOX=$_CONTAINER_TOOL' # Step 8: Publish sandbox container image - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' id: 'Publish sandbox Docker image' entrypoint: 'bash' args: - '-c' - |- set -e FINAL_IMAGE_URI=$$(cat /workspace/final_image_uri.txt) echo "Pushing sandbox image: $${FINAL_IMAGE_URI}" $_CONTAINER_TOOL push "$${FINAL_IMAGE_URI}" env: - 'GEMINI_SANDBOX=$_CONTAINER_TOOL' options: defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET' dynamicSubstitutions: true substitutions: _CONTAINER_TOOL: 'docker' ================================================ FILE: .gemini/config.yaml ================================================ # Config for the Gemini Pull Request Review Bot. # https://github.com/marketplace/gemini-code-assist have_fun: false code_review: disable: false comment_severity_threshold: 'HIGH' max_review_comments: -1 pull_request_opened: help: false summary: true code_review: true include_drafts: false ignore_patterns: [] ================================================ FILE: .gemini/settings.json ================================================ { "experimental": { "plan": true, "extensionReloading": true, "modelSteering": true, "memoryManager": true }, "general": { "devtools": true } } ================================================ FILE: .geminiignore ================================================ packages/core/src/services/scripts/*.exe ================================================ FILE: .gitattributes ================================================ # Set the default behavior for all files to automatically handle line endings. # This will ensure that all text files are normalized to use LF (line feed) # line endings in the repository, which helps prevent cross-platform issues. * text=auto eol=lf # Explicitly declare files that must have LF line endings for proper execution # on Unix-like systems. *.sh eol=lf *.bash eol=lf Makefile eol=lf # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.pdf binary *.woff binary *.woff2 binary *.eot binary *.ttf binary *.otf binary ================================================ FILE: .github/CODEOWNERS ================================================ # By default, require reviews from the maintainers for all files. * @google-gemini/gemini-cli-maintainers # Require reviews from the release approvers for critical files. # These patterns override the rule above. /package.json @google-gemini/gemini-cli-askmode-approvers /package-lock.json @google-gemini/gemini-cli-askmode-approvers /GEMINI.md @google-gemini/gemini-cli-askmode-approvers /SECURITY.md @google-gemini/gemini-cli-askmode-approvers /LICENSE @google-gemini/gemini-cli-askmode-approvers /.github/workflows/ @google-gemini/gemini-cli-askmode-approvers /packages/cli/package.json @google-gemini/gemini-cli-askmode-approvers /packages/core/package.json @google-gemini/gemini-cli-askmode-approvers # Docs have a dedicated approver group in addition to maintainers /docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs /README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs # Prompt contents, tool definitions, and evals require reviews from prompt approvers /packages/core/src/prompts/ @google-gemini/gemini-cli-prompt-approvers /packages/core/src/tools/ @google-gemini/gemini-cli-prompt-approvers /evals/ @google-gemini/gemini-cli-prompt-approvers ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 'Bug Report' description: 'Report a bug to help us improve Gemini CLI' body: - type: 'markdown' attributes: value: |- > [!IMPORTANT] > Thanks for taking the time to fill out this bug report! > > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if an issue already exists for the bug you encountered. - type: 'textarea' id: 'problem' attributes: label: 'What happened?' description: 'A clear and concise description of what the bug is.' validations: required: true - type: 'textarea' id: 'expected' attributes: label: 'What did you expect to happen?' validations: required: true - type: 'textarea' id: 'info' attributes: label: 'Client information' description: 'Please paste the full text from the `/about` command run from Gemini CLI. Also include which platform (macOS, Windows, Linux). Note that this output contains your email address. Consider removing it before submitting.' value: |-
Client Information Run `gemini` to enter the interactive CLI, then run the `/about` command. ```console > /about # paste output here ```
validations: required: true - type: 'textarea' id: 'login-info' attributes: label: 'Login information' description: 'Describe how you are logging in (e.g., Google Account, API key).' - type: 'textarea' id: 'additional-context' attributes: label: 'Anything else we need to know?' description: 'Add any other context about the problem here.' ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 'Feature Request' description: 'Suggest an idea for this project' labels: - 'status/need-triage' type: 'Feature' body: - type: 'markdown' attributes: value: |- > [!IMPORTANT] > Thanks for taking the time to suggest an enhancement! > > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if a similar feature has already been requested. - type: 'textarea' id: 'feature' attributes: label: 'What would you like to be added?' description: 'A clear and concise description of the enhancement.' validations: required: true - type: 'textarea' id: 'rationale' attributes: label: 'Why is this needed?' description: 'A clear and concise description of why this enhancement is needed.' validations: required: true - type: 'textarea' id: 'additional-context' attributes: label: 'Additional context' description: 'Add any other context or screenshots about the feature request here.' ================================================ FILE: .github/ISSUE_TEMPLATE/website_issue.yml ================================================ name: 'Website issue' description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' title: 'GeminiCLI.com Feedback: [ISSUE]' labels: - 'area/extensions' - 'area/documentation' body: - type: 'markdown' attributes: value: |- > [!IMPORTANT] > Thanks for taking the time to report an issue with the Gemini CLI Website > > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3Aarea%2Fwebsite)** to see if a similar feature has already been requested. - type: 'input' id: 'url' attributes: label: 'URL of the page with the issue' description: 'Please provide the URL where the issue occurs.' validations: required: true - type: 'textarea' id: 'problem' attributes: label: 'What is the problem?' description: 'A clear and concise description of what the bug or issue is.' validations: required: true - type: 'textarea' id: 'expected' attributes: label: 'What did you expect to happen?' validations: required: true - type: 'textarea' id: 'additional-context' attributes: label: 'Additional context' description: 'Add any other context or screenshots about the issue here.' ================================================ FILE: .github/actions/calculate-vars/action.yml ================================================ name: 'Calculate vars' description: 'Calculate commonly used var in our release process' inputs: dry_run: description: 'Whether or not this is a dry run' type: 'boolean' outputs: is_dry_run: description: 'Boolean flag indicating if the current run is a dry-run or a production release.' value: '${{ steps.set_vars.outputs.is_dry_run }}' runs: using: 'composite' steps: - name: 'Print inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Set vars for simplified logic' id: 'set_vars' shell: 'bash' env: DRY_RUN_INPUT: '${{ inputs.dry_run }}' run: |- is_dry_run="true" if [[ "${DRY_RUN_INPUT}" == "" || "${DRY_RUN_INPUT}" == "false" ]]; then is_dry_run="false" fi echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" ================================================ FILE: .github/actions/create-pull-request/action.yml ================================================ name: 'Create Pull Request' description: 'Creates a pull request.' inputs: branch-name: description: 'The name of the branch to create the PR from.' required: true pr-title: description: 'The title of the pull request.' required: true pr-body: description: 'The body of the pull request.' required: true base-branch: description: 'The branch to merge into.' required: true default: 'main' github-token: description: 'The GitHub token to use for creating the pull request.' required: true dry-run: description: 'Whether to run in dry-run mode.' required: false default: 'false' working-directory: description: 'The working directory to run the commands in.' required: false default: '.' runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Creates a Pull Request' if: "inputs.dry-run != 'true'" env: GH_TOKEN: '${{ inputs.github-token }}' INPUTS_BRANCH_NAME: '${{ inputs.branch-name }}' INPUTS_PR_TITLE: '${{ inputs.pr-title }}' INPUTS_PR_BODY: '${{ inputs.pr-body }}' INPUTS_BASE_BRANCH: '${{ inputs.base-branch }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | set -e if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository." exit 1 fi PR_URL=$(gh pr create \ --title "${INPUTS_PR_TITLE}" \ --body "${INPUTS_PR_BODY}" \ --base "${INPUTS_BASE_BRANCH}" \ --head "${INPUTS_BRANCH_NAME}" \ --fill) gh pr merge "$PR_URL" --auto ================================================ FILE: .github/actions/npm-auth-token/action.yml ================================================ name: 'NPM Auth Token' description: 'Generates an NPM auth token for publishing a specific package' inputs: package-name: description: 'The name of the package to publish' required: true github-token: description: 'the github token' required: true wombat-token-core: description: 'The npm token for the cli-core package.' required: true wombat-token-cli: description: 'The npm token for the cli package.' required: true wombat-token-a2a-server: description: 'The npm token for the a2a package.' required: true outputs: auth-token: description: 'The generated NPM auth token' value: '${{ steps.npm_auth_token.outputs.auth-token }}' runs: using: 'composite' steps: - name: 'Generate NPM Auth Token' id: 'npm_auth_token' shell: 'bash' run: | AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" PACKAGE_NAME="${INPUTS_PACKAGE_NAME}" PRIVATE_REPO="@google-gemini/" if [[ "$PACKAGE_NAME" == "$PRIVATE_REPO"* ]]; then AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli" ]]; then AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CLI}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-core" ]]; then AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CORE}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-a2a-server" ]]; then AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_A2A_SERVER}" fi echo "auth-token=$AUTH_TOKEN" >> $GITHUB_OUTPUT env: INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' INPUTS_PACKAGE_NAME: '${{ inputs.package-name }}' INPUTS_WOMBAT_TOKEN_CLI: '${{ inputs.wombat-token-cli }}' INPUTS_WOMBAT_TOKEN_CORE: '${{ inputs.wombat-token-core }}' INPUTS_WOMBAT_TOKEN_A2A_SERVER: '${{ inputs.wombat-token-a2a-server }}' ================================================ FILE: .github/actions/post-coverage-comment/action.yml ================================================ name: 'Post Coverage Comment Action' description: 'Prepares and posts a code coverage comment to a PR.' inputs: cli_json_file: description: 'Path to CLI coverage-summary.json' required: true core_json_file: description: 'Path to Core coverage-summary.json' required: true cli_full_text_summary_file: description: 'Path to CLI full-text-summary.txt' required: true core_full_text_summary_file: description: 'Path to Core full-text-summary.txt' required: true node_version: description: 'Node.js version for context in messages' required: true os: description: 'The os for context in messages' required: true github_token: description: 'GitHub token for posting comments' required: true runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Prepare Coverage Comment' id: 'prep_coverage_comment' shell: 'bash' env: CLI_JSON_FILE: '${{ inputs.cli_json_file }}' CORE_JSON_FILE: '${{ inputs.core_json_file }}' CLI_FULL_TEXT_SUMMARY_FILE: '${{ inputs.cli_full_text_summary_file }}' CORE_FULL_TEXT_SUMMARY_FILE: '${{ inputs.core_full_text_summary_file }}' COMMENT_FILE: 'coverage-comment.md' NODE_VERSION: '${{ inputs.node_version }}' OS: '${{ inputs.os }}' run: |- # Extract percentages using jq for the main table if [ -f "${CLI_JSON_FILE}" ]; then cli_lines_pct="$(jq -r '.total.lines.pct' "${CLI_JSON_FILE}")" cli_statements_pct="$(jq -r '.total.statements.pct' "${CLI_JSON_FILE}")" cli_functions_pct="$(jq -r '.total.functions.pct' "${CLI_JSON_FILE}")" cli_branches_pct="$(jq -r '.total.branches.pct' "${CLI_JSON_FILE}")" else cli_lines_pct="N/A" cli_statements_pct="N/A" cli_functions_pct="N/A" cli_branches_pct="N/A" echo "CLI coverage-summary.json not found at: ${CLI_JSON_FILE}" >&2 # Error to stderr fi if [ -f "${CORE_JSON_FILE}" ]; then core_lines_pct="$(jq -r '.total.lines.pct' "${CORE_JSON_FILE}")" core_statements_pct="$(jq -r '.total.statements.pct' "${CORE_JSON_FILE}")" core_functions_pct="$(jq -r '.total.functions.pct' "${CORE_JSON_FILE}")" core_branches_pct="$(jq -r '.total.branches.pct' "${CORE_JSON_FILE}")" else core_lines_pct="N/A" core_statements_pct="N/A" core_functions_pct="N/A" core_branches_pct="N/A" echo "Core coverage-summary.json not found at: ${CORE_JSON_FILE}" >&2 # Error to stderr fi echo "## Code Coverage Summary" > "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" echo "| Package | Lines | Statements | Functions | Branches |" >> "${COMMENT_FILE}" echo "|---|---|---|---|---|" >> "${COMMENT_FILE}" echo "| CLI | ${cli_lines_pct}% | ${cli_statements_pct}% | ${cli_functions_pct}% | ${cli_branches_pct}% |" >> "${COMMENT_FILE}" echo "| Core | ${core_lines_pct}% | ${core_statements_pct}% | ${core_functions_pct}% | ${core_branches_pct}% |" >> "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" # CLI Package - Collapsible Section (with full text summary from file) echo "
" >> "${COMMENT_FILE}" echo "CLI Package - Full Text Report" >> "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" echo '```text' >> "${COMMENT_FILE}" if [ -f "${CLI_FULL_TEXT_SUMMARY_FILE}" ]; then cat "${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" else echo "CLI full-text-summary.txt not found at: ${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" fi echo '```' >> "${COMMENT_FILE}" echo "
" >> "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" # Core Package - Collapsible Section (with full text summary from file) echo "
" >> "${COMMENT_FILE}" echo "Core Package - Full Text Report" >> "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" echo '```text' >> "${COMMENT_FILE}" if [ -f "${CORE_FULL_TEXT_SUMMARY_FILE}" ]; then cat "${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" else echo "Core full-text-summary.txt not found at: ${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" fi echo '```' >> "${COMMENT_FILE}" echo "
" >> "${COMMENT_FILE}" echo "" >> "${COMMENT_FILE}" echo "_For detailed HTML reports, please see the 'coverage-reports-${NODE_VERSION}-${OS}' artifact from the main CI run._" >> "${COMMENT_FILE}" - name: 'Post Coverage Comment' uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3 if: |- ${{ always() }} with: file-path: 'coverage-comment.md' # Use the generated file directly comment-tag: 'code-coverage-summary' github-token: '${{ inputs.github_token }}' ================================================ FILE: .github/actions/publish-release/action.yml ================================================ name: 'Publish Release' description: 'Builds, prepares, and publishes the gemini-cli packages to npm and creates a GitHub release.' inputs: release-version: description: 'The version to release (e.g., 0.1.11).' required: true npm-tag: description: 'The npm tag to publish with (e.g., latest, preview, nightly).' required: true wombat-token-core: description: 'The npm token for the cli-core package.' required: true wombat-token-cli: description: 'The npm token for the cli package.' required: true wombat-token-a2a-server: description: 'The npm token for the a2a package.' required: true github-token: description: 'The GitHub token for creating the release.' required: true github-release-token: description: 'The GitHub token used specifically for creating the GitHub release (to trigger other workflows).' required: false dry-run: description: 'Whether to run in dry-run mode.' type: 'string' required: true release-tag: description: 'The release tag for the release (e.g., v0.1.11).' required: true previous-tag: description: 'The previous tag to use for generating release notes.' required: true skip-github-release: description: 'Whether to skip creating a GitHub release.' type: 'boolean' required: false default: false working-directory: description: 'The working directory to run the steps in.' required: false default: '.' force-skip-tests: description: 'Skip tests and validation' required: false default: false skip-branch-cleanup: description: 'Whether to skip cleaning up the release branch.' type: 'boolean' required: false default: false gemini_api_key: description: 'The API key for running integration tests.' required: true npm-registry-publish-url: description: 'npm registry publish url' required: true npm-registry-url: description: 'npm registry url' required: true npm-registry-scope: description: 'npm registry scope' required: true cli-package-name: description: 'The name of the cli package.' required: true core-package-name: description: 'The name of the core package.' required: true a2a-package-name: description: 'The name of the a2a package.' required: true runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: '👤 Configure Git User' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | git config user.name "gemini-cli-robot" git config user.email "gemini-cli-robot@google.com" - name: '🌿 Create and switch to a release branch' working-directory: '${{ inputs.working-directory }}' id: 'release_branch' shell: 'bash' run: | BRANCH_NAME="release/${INPUTS_RELEASE_TAG}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" env: INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' - name: '⬆️ Update package versions' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | npm run release:version "${INPUTS_RELEASE_VERSION}" env: INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' - name: '💾 Commit and Conditionally Push package versions' working-directory: '${{ inputs.working-directory }}' shell: 'bash' env: BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' DRY_RUN: '${{ inputs.dry-run }}' RELEASE_TAG: '${{ inputs.release-tag }}' run: |- set -e git add package.json package-lock.json packages/*/package.json git commit -m "chore(release): ${RELEASE_TAG}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." git push --set-upstream origin "${BRANCH_NAME}" --follow-tags else echo "Dry run enabled. Skipping push." fi - name: '🛠️ Build and Prepare Packages' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | npm run build:packages npm run prepare:package - name: '🎁 Bundle' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | npm run bundle # TODO: Refactor this github specific publishing script to be generalized based upon inputs. - name: '📦 Prepare for GitHub release' if: "inputs.npm-registry-url == 'https://npm.pkg.github.com/'" working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | node ${{ github.workspace }}/scripts/prepare-github-release.js - name: 'Configure npm for publishing to npm' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '${{ inputs.working-directory }}/.nvmrc' registry-url: '${{inputs.npm-registry-publish-url}}' scope: '${{inputs.npm-registry-scope}}' - name: 'Get core Token' uses: './.github/actions/npm-auth-token' id: 'core-token' with: package-name: '${{ inputs.core-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: '📦 Publish CORE to NPM' working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' INPUTS_DRY_RUN: '${{ inputs.dry-run }}' INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' shell: 'bash' run: | npm publish \ --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CORE_PACKAGE_NAME}" \ --no-tag npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent - name: '🔗 Install latest core package' working-directory: '${{ inputs.working-directory }}' if: "${{ inputs.dry-run != 'true' }}" shell: 'bash' run: | npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --save-exact env: INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' - name: '📦 Prepare bundled CLI for npm release' if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'" working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | node ${{ github.workspace }}/scripts/prepare-npm-release.js - name: 'Get CLI Token' uses: './.github/actions/npm-auth-token' id: 'cli-token' with: package-name: '${{ inputs.cli-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: '📦 Publish CLI' working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' INPUTS_DRY_RUN: '${{ inputs.dry-run }}' INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' shell: 'bash' run: | npm publish \ --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' id: 'a2a-token' with: package-name: '${{ inputs.a2a-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: '📦 Publish a2a' working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' INPUTS_DRY_RUN: '${{ inputs.dry-run }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' shell: 'bash' # Tag staging for initial release run: | npm publish \ --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --no-tag npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent - name: '🔬 Verify NPM release by version' uses: './.github/actions/verify-release' if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}" with: npm-package: '${{ inputs.cli-package-name }}@${{ inputs.release-version }}' expected-version: '${{ inputs.release-version }}' working-directory: '${{ inputs.working-directory }}' gemini_api_key: '${{ inputs.gemini_api_key }}' github-token: '${{ inputs.github-token }}' npm-registry-url: '${{ inputs.npm-registry-url }}' npm-registry-scope: '${{ inputs.npm-registry-scope }}' - name: '🏷️ Tag release' uses: './.github/actions/tag-npm-release' with: channel: '${{ inputs.npm-tag }}' version: '${{ inputs.release-version }}' dry-run: '${{ inputs.dry-run }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' cli-package-name: '${{ inputs.cli-package-name }}' core-package-name: '${{ inputs.core-package-name }}' a2a-package-name: '${{ inputs.a2a-package-name }}' working-directory: '${{ inputs.working-directory }}' - name: '🎉 Create GitHub Release' working-directory: '${{ inputs.working-directory }}' if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}" env: GITHUB_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}' shell: 'bash' run: | gh release create "${INPUTS_RELEASE_TAG}" \ bundle/gemini.js \ --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \ --title "Release ${INPUTS_RELEASE_TAG}" \ --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \ --generate-notes \ ${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }} - name: '🧹 Clean up release branch' working-directory: '${{ inputs.working-directory }}' if: "${{ inputs.dry-run != 'true' && inputs.skip-branch-cleanup != 'true' }}" continue-on-error: true shell: 'bash' run: | echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..." git push origin --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" env: STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' ================================================ FILE: .github/actions/push-docker/action.yml ================================================ name: 'Push to docker' description: 'Builds packages and pushes a docker image to GHCR' inputs: github-actor: description: 'Github actor' required: true github-secret: description: 'Github secret' required: true ref-name: description: 'Github ref name' required: true github-sha: description: 'Github Commit SHA Hash' required: true runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 with: ref: '${{ inputs.github-sha }}' fetch-depth: 0 - name: 'Install Dependencies' shell: 'bash' run: 'npm install' - name: 'Set up Docker Buildx' uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 - name: 'build' shell: 'bash' run: 'npm run build' - name: 'pack @google/gemini-cli' shell: 'bash' run: 'npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist' - name: 'pack @google/gemini-cli-core' shell: 'bash' run: 'npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist' - name: 'Log in to GitHub Container Registry' uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 with: registry: 'ghcr.io' username: '${{ inputs.github-actor }}' password: '${{ inputs.github-secret }}' - name: 'Get branch name' id: 'branch_name' shell: 'bash' run: | REF_NAME="${INPUTS_REF_NAME}" echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT env: INPUTS_REF_NAME: '${{ inputs.ref-name }}' - name: 'Build and Push the Docker Image' uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6 with: context: '.' file: './Dockerfile' push: true provenance: false # avoid pushing 3 images to Aritfact Registry tags: | ghcr.io/${{ github.repository }}/cli:${{ steps.branch_name.outputs.name }} ghcr.io/${{ github.repository }}/cli:${{ inputs.github-sha }} - name: 'Create issue on failure' if: |- ${{ failure() }} shell: 'bash' env: GITHUB_TOKEN: '${{ inputs.github-secret }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: |- gh issue create \ --title "Docker build failed" \ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \ --label "release-failure" ================================================ FILE: .github/actions/push-sandbox/action.yml ================================================ name: 'Build and push sandbox docker' description: 'Pushes sandbox docker image to container registry' inputs: github-actor: description: 'Github actor' required: true github-secret: description: 'Github secret' required: true dockerhub-username: description: 'Dockerhub username' required: true dockerhub-token: description: 'Dockerhub PAT w/ R+W' required: true github-sha: description: 'Github Commit SHA Hash' required: true github-ref-name: description: 'Github ref name' required: true dry-run: description: 'Whether this is a dry run.' required: true type: 'boolean' runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Checkout' uses: 'actions/checkout@v4' with: ref: '${{ inputs.github-sha }}' fetch-depth: 0 - name: 'Install Dependencies' shell: 'bash' run: 'npm install' - name: 'npm build' shell: 'bash' run: 'npm run build' - name: 'Set up QEMU' uses: 'docker/setup-qemu-action@v3' - name: 'Set up Docker Buildx' uses: 'docker/setup-buildx-action@v3' - name: 'Log in to GitHub Container Registry' uses: 'docker/login-action@v3' with: registry: 'docker.io' username: '${{ inputs.dockerhub-username }}' password: '${{ inputs.dockerhub-token }}' - name: 'determine image tag' id: 'image_tag' shell: 'bash' run: |- SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}" FINAL_TAG="${INPUTS_GITHUB_SHA}" if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "Release detected." FINAL_TAG="${SHELL_TAG_NAME#v}" else echo "Development release detected. Using commit SHA as tag." fi echo "Determined image tag: $FINAL_TAG" echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT env: INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}' INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}' # We build amd64 just so we can verify it. # We build and push both amd64 and arm64 in the publish step. - name: 'build' id: 'docker_build' shell: 'bash' env: GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' GEMINI_SANDBOX: 'docker' BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load' STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' run: |- npm run build:sandbox -- \ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \ --output-file final_image_uri.txt echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT - name: 'verify' shell: 'bash' run: |- docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc ' set -e node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));" /usr/local/share/npm-global/bin/gemini --version >/dev/null ' - name: 'publish' shell: 'bash' if: "${{ inputs.dry-run != 'true' }}" env: GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' GEMINI_SANDBOX: 'docker' BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push' STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' run: |- npm run build:sandbox -- \ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" - name: 'Create issue on failure' if: |- ${{ failure() }} shell: 'bash' env: GITHUB_TOKEN: '${{ inputs.github-secret }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: |- gh issue create \ --title "Docker build failed" \ --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \ --label "release-failure" ================================================ FILE: .github/actions/run-tests/action.yml ================================================ name: 'Run Tests' description: 'Runs the preflight checks and integration tests.' inputs: gemini_api_key: description: 'The API key for running integration tests.' required: true working-directory: description: 'The working directory to run the tests in.' required: false default: '.' runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Run Tests' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' working-directory: '${{ inputs.working-directory }}' run: |- echo "::group::Build" npm run build echo "::endgroup::" echo "::group::Unit Tests" npm run test:ci echo "::endgroup::" echo "::group::Integration Tests (no sandbox)" npm run test:integration:sandbox:none echo "::endgroup::" echo "::group::Integration Tests (docker sandbox)" npm run test:integration:sandbox:docker echo "::endgroup::" shell: 'bash' ================================================ FILE: .github/actions/setup-npmrc/action.yml ================================================ name: 'Setup NPMRC' description: 'Sets up NPMRC with all the correct repos for readonly access.' inputs: github-token: description: 'the github token' required: true outputs: auth-token: description: 'The generated NPM auth token' value: '${{ steps.npm_auth_token.outputs.auth-token }}' runs: using: 'composite' steps: - name: 'Configure .npmrc' shell: 'bash' run: |- echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc env: INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' ================================================ FILE: .github/actions/tag-npm-release/action.yml ================================================ name: 'Tag an NPM release' description: 'Tags a specific npm version to a specific channel.' inputs: channel: description: 'NPM Channel tag' required: true version: description: 'version' required: true dry-run: description: 'Whether to run in dry-run mode.' required: true github-token: description: 'The GitHub token for creating the release.' required: true wombat-token-core: description: 'The npm token for the wombat @google/gemini-cli-core' required: true wombat-token-cli: description: 'The npm token for wombat @google/gemini-cli' required: true wombat-token-a2a-server: description: 'The npm token for the @google/gemini-cli-a2a-server package.' required: true cli-package-name: description: 'The name of the cli package.' required: true core-package-name: description: 'The name of the core package.' required: true a2a-package-name: description: 'The name of the a2a package.' required: true working-directory: description: 'The working directory to run the commands in.' required: false default: '.' runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '${{ inputs.working-directory }}/.nvmrc' - name: 'configure .npmrc' uses: './.github/actions/setup-npmrc' with: github-token: '${{ inputs.github-token }}' - name: 'Get core Token' uses: './.github/actions/npm-auth-token' id: 'core-token' with: package-name: '${{ inputs.core-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: 'Change tag for CORE' if: |- ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' INPUTS_VERSION: '${{ inputs.version }}' INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get cli Token' uses: './.github/actions/npm-auth-token' id: 'cli-token' with: package-name: '${{ inputs.cli-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: 'Change tag for CLI' if: |- ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' INPUTS_VERSION: '${{ inputs.version }}' INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get a2a Token' uses: './.github/actions/npm-auth-token' id: 'a2a-token' with: package-name: '${{ inputs.a2a-package-name }}' github-token: '${{ inputs.github-token }}' wombat-token-core: '${{ inputs.wombat-token-core }}' wombat-token-cli: '${{ inputs.wombat-token-cli }}' wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' - name: 'Change tag for a2a' if: |- ${{ inputs.dry-run == 'false' }} env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' INPUTS_VERSION: '${{ inputs.version }}' INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Log dry run' if: |- ${{ inputs.dry-run == 'true' }} shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | echo "Dry run: Would have added tag '${INPUTS_CHANNEL}' to version '${INPUTS_VERSION}' for ${INPUTS_CLI_PACKAGE_NAME}, ${INPUTS_CORE_PACKAGE_NAME}, and ${INPUTS_A2A_PACKAGE_NAME}." env: INPUTS_CHANNEL: '${{ inputs.channel }}' INPUTS_VERSION: '${{ inputs.version }}' INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' ================================================ FILE: .github/actions/verify-release/action.yml ================================================ name: 'Verify an NPM release' description: 'Fetches a package from NPM and does some basic smoke tests' inputs: npm-package: description: 'NPM Package' required: true default: '@google/gemini-cli@latest' npm-registry-url: description: 'NPM Registry URL' required: true npm-registry-scope: description: 'NPM Registry Scope' required: true expected-version: description: 'Expected version' required: true gemini_api_key: description: 'The API key for running integration tests.' required: true github-token: description: 'The GitHub token for running integration tests.' required: true working-directory: description: 'The working directory to run the tests in.' required: false default: '.' runs: using: 'composite' steps: - name: '📝 Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'setup node' uses: 'actions/setup-node@v4' with: node-version: '20' - name: 'configure .npmrc' uses: './.github/actions/setup-npmrc' with: github-token: '${{ inputs.github-token }}' - name: 'Clear npm cache' shell: 'bash' run: 'npm cache clean --force' - name: 'Install from NPM' uses: 'nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08' # ratchet:nick-fields/retry@v3 with: timeout_seconds: 900 retry_wait_seconds: 30 max_attempts: 10 command: |- cd ${{ inputs.working-directory }} npm install --prefer-online --no-cache -g "${{ inputs.npm-package }}" - name: 'Smoke test - NPM Install' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: |- gemini_version=$(gemini --version) if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi env: INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' - name: 'Clear npm cache' shell: 'bash' run: 'npm cache clean --force' - name: 'Smoke test - NPX Run' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: |- gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version) if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi env: INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' - name: 'Install dependencies for integration tests' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: 'npm ci' - name: '🔬 Run integration tests against NPM release' working-directory: '${{ inputs.working-directory }}' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true' # We must diable CI mode here because it interferes with interactive tests. # See https://github.com/google-gemini/gemini-cli/issues/10517 CI: 'false' shell: 'bash' run: 'npm run test:integration:sandbox:none' ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: interval: 'weekly' day: 'monday' open-pull-requests-limit: 10 reviewers: - 'joshualitt' groups: npm-dependencies: patterns: - '*' update-types: - 'minor' - 'patch' - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'weekly' day: 'monday' open-pull-requests-limit: 10 reviewers: - 'joshualitt' groups: actions-dependencies: patterns: - '*' update-types: - 'minor' - 'patch' ================================================ FILE: .github/pull_request_template.md ================================================ ## Summary ## Details ## Related Issues ## How to Validate ## Pre-Merge Checklist - [ ] Updated relevant documentation and README (if needed) - [ ] Added/updated tests (if needed) - [ ] Noted breaking changes (if any) - [ ] Validated on required platforms/methods: - [ ] MacOS - [ ] npm run - [ ] npx - [ ] Docker - [ ] Podman - [ ] Seatbelt - [ ] Windows - [ ] npm run - [ ] npx - [ ] Docker - [ ] Linux - [ ] npm run - [ ] npx - [ ] Docker ================================================ FILE: .github/scripts/backfill-need-triage.cjs ================================================ /* eslint-disable */ /* global require, console, process */ /** * Script to backfill the 'status/need-triage' label to all open issues * that are NOT currently labeled with '🔒 maintainer only' or 'help wanted'. */ const { execFileSync } = require('child_process'); const isDryRun = process.argv.includes('--dry-run'); const REPO = 'google-gemini/gemini-cli'; /** * Executes a GitHub CLI command safely using an argument array to prevent command injection. * @param {string[]} args * @returns {string|null} */ function runGh(args) { try { // Using execFileSync with an array of arguments is safe as it doesn't use a shell. // We set a large maxBuffer (10MB) to handle repositories with many issues. return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); } catch (error) { const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; console.error( `❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, ); return null; } } async function main() { console.log('🔐 GitHub CLI security check...'); const authStatus = runGh(['auth', 'status']); if (authStatus === null) { console.error('❌ GitHub CLI (gh) is not installed or not authenticated.'); process.exit(1); } if (isDryRun) { console.log('🧪 DRY RUN MODE ENABLED - No changes will be made.\n'); } console.log(`🔍 Fetching and filtering open issues from ${REPO}...`); // We use the /issues endpoint with pagination to bypass the 1000-result limit. // The jq filter ensures we exclude PRs, maintainer-only, help-wanted, and existing status/need-triage. const jqFilter = '.[] | select(.pull_request == null) | select([.labels[].name] as $l | (any($l[]; . == "🔒 maintainer only") | not) and (any($l[]; . == "help wanted") | not) and (any($l[]; . == "status/need-triage") | not)) | {number: .number, title: .title}'; const output = runGh([ 'api', `repos/${REPO}/issues?state=open&per_page=100`, '--paginate', '--jq', jqFilter, ]); if (output === null) { process.exit(1); } const issues = output .split('\n') .filter((line) => line.trim()) .map((line) => { try { return JSON.parse(line); } catch (_e) { console.error(`⚠️ Failed to parse line: ${line}`); return null; } }) .filter(Boolean); console.log(`✅ Found ${issues.length} issues matching criteria.`); if (issues.length === 0) { console.log('✨ No issues need backfilling.'); return; } let successCount = 0; let failCount = 0; if (isDryRun) { for (const issue of issues) { console.log( `[DRY RUN] Would label issue #${issue.number}: ${issue.title}`, ); } successCount = issues.length; } else { console.log(`🏷️ Applying labels to ${issues.length} issues...`); for (const issue of issues) { const issueNumber = String(issue.number); console.log(`🏷️ Labeling issue #${issueNumber}: ${issue.title}`); const result = runGh([ 'issue', 'edit', issueNumber, '--add-label', 'status/need-triage', '--repo', REPO, ]); if (result !== null) { successCount++; } else { failCount++; } } } console.log(`\n📊 Summary:`); console.log(` - Success: ${successCount}`); console.log(` - Failed: ${failCount}`); if (failCount > 0) { console.error(`\n❌ Backfill completed with ${failCount} errors.`); process.exit(1); } else { console.log(`\n🎉 ${isDryRun ? 'Dry run' : 'Backfill'} complete!`); } } main().catch((error) => { console.error('❌ Unexpected error:', error); process.exit(1); }); ================================================ FILE: .github/scripts/backfill-pr-notification.cjs ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /* eslint-disable */ /* global require, console, process */ /** * Script to backfill a process change notification comment to all open PRs * not created by members of the 'gemini-cli-maintainers' team. * * Skip PRs that are already associated with an issue. */ const { execFileSync } = require('child_process'); const isDryRun = process.argv.includes('--dry-run'); const REPO = 'google-gemini/gemini-cli'; const ORG = 'google-gemini'; const TEAM_SLUG = 'gemini-cli-maintainers'; const DISCUSSION_URL = 'https://github.com/google-gemini/gemini-cli/discussions/16706'; /** * Executes a GitHub CLI command safely using an argument array. */ function runGh(args, options = {}) { const { silent = false } = options; try { return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); } catch (error) { if (!silent) { const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; console.error( `❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, ); } return null; } } /** * Checks if a user is a member of the maintainers team. */ const membershipCache = new Map(); function isMaintainer(username) { if (membershipCache.has(username)) return membershipCache.get(username); // GitHub returns 404 if user is not a member. // We use silent: true to avoid logging 404s as errors. const result = runGh( ['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`], { silent: true }, ); const isMember = result !== null; membershipCache.set(username, isMember); return isMember; } async function main() { console.log('🔐 GitHub CLI security check...'); if (runGh(['auth', 'status']) === null) { console.error('❌ GitHub CLI (gh) is not authenticated.'); process.exit(1); } if (isDryRun) { console.log('🧪 DRY RUN MODE ENABLED\n'); } console.log(`📥 Fetching open PRs from ${REPO}...`); // Fetch number, author, and closingIssuesReferences to check if linked to an issue const prsJson = runGh([ 'pr', 'list', '--repo', REPO, '--state', 'open', '--limit', '1000', '--json', 'number,author,closingIssuesReferences', ]); if (prsJson === null) process.exit(1); const prs = JSON.parse(prsJson); console.log(`📊 Found ${prs.length} open PRs. Filtering...`); let targetPrs = []; for (const pr of prs) { const author = pr.author.login; const issueCount = pr.closingIssuesReferences ? pr.closingIssuesReferences.length : 0; if (issueCount > 0) { // Skip if already linked to an issue continue; } if (!isMaintainer(author)) { targetPrs.push(pr); } } console.log( `✅ Found ${targetPrs.length} PRs from non-maintainers without associated issues.`, ); const commentBody = "\nHi @{AUTHOR}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.\n\nWe're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](${DISCUSSION_URL}).\n\nKey Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.\n\nThank you for your understanding and for being a part of our community!\n ".trim(); let successCount = 0; let skipCount = 0; let failCount = 0; for (const pr of targetPrs) { const prNumber = String(pr.number); const author = pr.author.login; // Check if we already commented (idempotency) // We use silent: true here because view might fail if PR is deleted mid-run const existingComments = runGh( [ 'pr', 'view', prNumber, '--repo', REPO, '--json', 'comments', '--jq', `.comments[].body | contains("${DISCUSSION_URL}")`, ], { silent: true }, ); if (existingComments && existingComments.includes('true')) { console.log( `⏭️ PR #${prNumber} already has the notification. Skipping.`, ); skipCount++; continue; } if (isDryRun) { console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`); successCount++; } else { console.log(`💬 Notifying @${author} on PR #${prNumber}...`); const personalizedComment = commentBody.replace('{AUTHOR}', author); const result = runGh([ 'pr', 'comment', prNumber, '--repo', REPO, '--body', personalizedComment, ]); if (result !== null) { successCount++; } else { failCount++; } } } console.log(`\n📊 Summary:`); console.log(` - Notified: ${successCount}`); console.log(` - Skipped: ${skipCount}`); console.log(` - Failed: ${failCount}`); if (failCount > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); }); ================================================ FILE: .github/scripts/pr-triage.sh ================================================ #!/usr/bin/env bash # @license # Copyright 2026 Google LLC # SPDX-License-Identifier: Apache-2.0 set -euo pipefail # Initialize a comma-separated string to hold PR numbers that need a comment PRS_NEEDING_COMMENT="" # Global cache for issue labels (compatible with Bash 3.2) # Stores "|ISSUE_NUM:LABELS|" segments ISSUE_LABELS_CACHE_FLAT="|" # Function to get labels from an issue (with caching) get_issue_labels() { local ISSUE_NUM="${1}" if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then return fi # Check cache case "${ISSUE_LABELS_CACHE_FLAT}" in *"|${ISSUE_NUM}:"*) local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}" echo "${suffix%%|*}" return ;; *) # Cache miss, proceed to fetch ;; esac echo " 📥 Fetching labels from issue #${ISSUE_NUM}" >&2 local gh_output if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then echo " ⚠️ Could not fetch issue #${ISSUE_NUM}" >&2 ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|" return fi local labels labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|🔒 maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "") # Save to flat cache ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|" echo "${labels}" } # Function to process a single PR with pre-fetched data process_pr_optimized() { local PR_NUMBER="${1}" local IS_DRAFT="${2}" local ISSUE_NUMBER="${3}" local CURRENT_LABELS="${4}" # Comma-separated labels echo "🔄 Processing PR #${PR_NUMBER}" local LABELS_TO_ADD="" local LABELS_TO_REMOVE="" if [[ -z "${ISSUE_NUMBER}" || "${ISSUE_NUMBER}" == "null" || "${ISSUE_NUMBER}" == "" ]]; then if [[ "${IS_DRAFT}" == "true" ]]; then echo " 📝 PR #${PR_NUMBER} is a draft and has no linked issue" if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then echo " ➖ Removing status/need-issue label" LABELS_TO_REMOVE="status/need-issue" fi else echo " ⚠️ No linked issue found for PR #${PR_NUMBER}" if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then echo " ➕ Adding status/need-issue label" LABELS_TO_ADD="status/need-issue" fi if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then PRS_NEEDING_COMMENT="${PR_NUMBER}" else PRS_NEEDING_COMMENT="${PRS_NEEDING_COMMENT},${PR_NUMBER}" fi fi else echo " 🔗 Found linked issue #${ISSUE_NUMBER}" if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then echo " ➖ Removing status/need-issue label" LABELS_TO_REMOVE="status/need-issue" fi local ISSUE_LABELS ISSUE_LABELS=$(get_issue_labels "${ISSUE_NUMBER}") if [[ -n "${ISSUE_LABELS}" ]]; then local IFS_OLD="${IFS}" IFS=',' for label in ${ISSUE_LABELS}; do if [[ -n "${label}" ]] && [[ ",${CURRENT_LABELS}," != *",${label},"* ]]; then if [[ -z "${LABELS_TO_ADD}" ]]; then LABELS_TO_ADD="${label}" else LABELS_TO_ADD="${LABELS_TO_ADD},${label}" fi fi done IFS="${IFS_OLD}" fi if [[ -z "${LABELS_TO_ADD}" && -z "${LABELS_TO_REMOVE}" ]]; then echo " ✅ Labels already synchronized" fi fi if [[ -n "${LABELS_TO_ADD}" || -n "${LABELS_TO_REMOVE}" ]]; then local EDIT_CMD=("gh" "pr" "edit" "${PR_NUMBER}" "--repo" "${GITHUB_REPOSITORY}") if [[ -n "${LABELS_TO_ADD}" ]]; then echo " ➕ Syncing labels to add: ${LABELS_TO_ADD}" EDIT_CMD+=("--add-label" "${LABELS_TO_ADD}") fi if [[ -n "${LABELS_TO_REMOVE}" ]]; then echo " ➖ Syncing labels to remove: ${LABELS_TO_REMOVE}" EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}") fi ("${EDIT_CMD[@]}" || true) fi } if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then echo "‼️ Missing \$GITHUB_REPOSITORY - this must be run from GitHub Actions" exit 1 fi if [[ -z "${GITHUB_OUTPUT:-}" ]]; then echo "‼️ Missing \$GITHUB_OUTPUT - this must be run from GitHub Actions" exit 1 fi JQ_EXTRACT_FIELDS='{ number: .number, isDraft: .isDraft, issue: (.closingIssuesReferences[0].number // (.body // "" | capture("(^|[^a-zA-Z0-9])#(?[0-9]+)([^a-zA-Z0-9]|$)")? | .num) // "null"), labels: [.labels[].name] | join(",") }' JQ_TSV_FORMAT='"\((.number | tostring))\t\(.isDraft)\t\((.issue // null) | tostring)\t\(.labels)"' if [[ -n "${PR_NUMBER:-}" ]]; then echo "🔄 Processing single PR #${PR_NUMBER}" PR_DATA=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { echo "❌ Failed to fetch data for PR #${PR_NUMBER}" exit 1 } line=$(echo "${PR_DATA}" | jq -r "${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}") IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" else echo "📥 Getting all open pull requests..." PR_DATA_ALL=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { echo "❌ Failed to fetch PR list" exit 1 } PR_COUNT=$(echo "${PR_DATA_ALL}" | jq '. | length') echo "📊 Found ${PR_COUNT} open PRs to process" # Use a temporary file to avoid masking exit codes in process substitution tmp_file=$(mktemp) echo "${PR_DATA_ALL}" | jq -r ".[] | ${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}" > "${tmp_file}" while read -r line; do [[ -z "${line}" ]] && continue IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" done < "${tmp_file}" rm -f "${tmp_file}" fi if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" else echo "prs_needing_comment=[${PRS_NEEDING_COMMENT}]" >> "${GITHUB_OUTPUT}" fi echo "✅ PR triage completed" ================================================ FILE: .github/scripts/sync-maintainer-labels.cjs ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const { Octokit } = require('@octokit/rest'); /** * Sync Maintainer Labels (Recursive with strict parent-child relationship detection) * - Uses Native Sub-issues. * - Uses Markdown Task Lists (- [ ] #123). * - Filters for OPEN issues only. * - Skips DUPLICATES. * - Skips Pull Requests. * - ONLY labels issues in the PUBLIC (gemini-cli) repo. */ const REPO_OWNER = 'google-gemini'; const PUBLIC_REPO = 'gemini-cli'; const PRIVATE_REPO = 'maintainers-gemini-cli'; const ALLOWED_REPOS = [PUBLIC_REPO, PRIVATE_REPO]; const ROOT_ISSUES = [ { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15374 }, { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15456 }, { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15324 }, ]; const TARGET_LABEL = '🔒 maintainer only'; const isDryRun = process.argv.includes('--dry-run') || process.env.DRY_RUN === 'true'; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, }); /** * Extracts child issue references from markdown Task Lists ONLY. * e.g. - [ ] #123 or - [x] google-gemini/gemini-cli#123 */ function extractTaskListLinks(text, contextOwner, contextRepo) { if (!text) return []; const childIssues = new Map(); const add = (owner, repo, number) => { if (ALLOWED_REPOS.includes(repo)) { const key = `${owner}/${repo}#${number}`; childIssues.set(key, { owner, repo, number: parseInt(number, 10) }); } }; // 1. Full URLs in task lists const urlRegex = /-\s+\[[ x]\].*https:\/\/github\.com\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)\/issues\/(\d+)\b/g; let match; while ((match = urlRegex.exec(text)) !== null) { add(match[1], match[2], match[3]); } // 2. Cross-repo refs in task lists: owner/repo#123 const crossRepoRegex = /-\s+\[[ x]\].*([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)#(\d+)\b/g; while ((match = crossRepoRegex.exec(text)) !== null) { add(match[1], match[2], match[3]); } // 3. Short refs in task lists: #123 const shortRefRegex = /-\s+\[[ x]\].*#(\d+)\b/g; while ((match = shortRefRegex.exec(text)) !== null) { add(contextOwner, contextRepo, match[1]); } return Array.from(childIssues.values()); } /** * Fetches issue data via GraphQL with full pagination for sub-issues, comments, and labels. */ async function fetchIssueData(owner, repo, number) { const query = ` query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { issue(number:$number) { state title body labels(first: 100) { nodes { name } pageInfo { hasNextPage endCursor } } subIssues(first: 100) { nodes { number repository { name owner { login } } } pageInfo { hasNextPage endCursor } } comments(first: 100) { nodes { body } } } } } `; try { const response = await octokit.graphql(query, { owner, repo, number }); const data = response.repository.issue; if (!data) return null; const issue = { state: data.state, title: data.title, body: data.body || '', labels: data.labels.nodes.map((n) => n.name), subIssues: [...data.subIssues.nodes], comments: data.comments.nodes.map((n) => n.body), }; // Paginate subIssues if there are more than 100 if (data.subIssues.pageInfo.hasNextPage) { const moreSubIssues = await paginateConnection( owner, repo, number, 'subIssues', 'number repository { name owner { login } }', data.subIssues.pageInfo.endCursor, ); issue.subIssues.push(...moreSubIssues); } // Paginate labels if there are more than 100 (unlikely but for completeness) if (data.labels.pageInfo.hasNextPage) { const moreLabels = await paginateConnection( owner, repo, number, 'labels', 'name', data.labels.pageInfo.endCursor, (n) => n.name, ); issue.labels.push(...moreLabels); } // Note: Comments are handled via Task Lists in body + first 100 comments. // If an issue has > 100 comments with task lists, we'd need to paginate those too. // Given the 1,100+ issue discovery count, 100 comments is usually sufficient, // but we can add it for absolute completeness. // (Skipping for now to avoid excessive API churn unless clearly needed). return issue; } catch (error) { if (error.errors && error.errors.some((e) => e.type === 'NOT_FOUND')) { return null; } throw error; } } /** * Helper to paginate any GraphQL connection. */ async function paginateConnection( owner, repo, number, connectionName, nodeFields, initialCursor, transformNode = (n) => n, ) { let additionalNodes = []; let hasNext = true; let cursor = initialCursor; while (hasNext) { const query = ` query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { repository(owner:$owner, name:$repo) { issue(number:$number) { ${connectionName}(first: 100, after: $cursor) { nodes { ${nodeFields} } pageInfo { hasNextPage endCursor } } } } } `; const response = await octokit.graphql(query, { owner, repo, number, cursor, }); const connection = response.repository.issue[connectionName]; additionalNodes.push(...connection.nodes.map(transformNode)); hasNext = connection.pageInfo.hasNextPage; cursor = connection.pageInfo.endCursor; } return additionalNodes; } /** * Validates if an issue should be processed (Open, not a duplicate, not a PR) */ function shouldProcess(issueData) { if (!issueData) return false; if (issueData.state !== 'OPEN') return false; const labels = issueData.labels.map((l) => l.toLowerCase()); if (labels.includes('duplicate') || labels.includes('kind/duplicate')) { return false; } return true; } async function getAllDescendants(roots) { const allDescendants = new Map(); const visited = new Set(); const queue = [...roots]; for (const root of roots) { visited.add(`${root.owner}/${root.repo}#${root.number}`); } console.log(`Starting discovery from ${roots.length} roots...`); while (queue.length > 0) { const current = queue.shift(); const currentKey = `${current.owner}/${current.repo}#${current.number}`; try { const issueData = await fetchIssueData( current.owner, current.repo, current.number, ); if (!shouldProcess(issueData)) { continue; } // ONLY add to labeling list if it's in the PUBLIC repository if (current.repo === PUBLIC_REPO) { // Don't label the roots themselves if ( !ROOT_ISSUES.some( (r) => r.number === current.number && r.repo === current.repo, ) ) { allDescendants.set(currentKey, { ...current, title: issueData.title, labels: issueData.labels, }); } } const children = new Map(); // 1. Process Native Sub-issues if (issueData.subIssues) { for (const node of issueData.subIssues) { const childOwner = node.repository.owner.login; const childRepo = node.repository.name; const childNumber = node.number; const key = `${childOwner}/${childRepo}#${childNumber}`; children.set(key, { owner: childOwner, repo: childRepo, number: childNumber, }); } } // 2. Process Markdown Task Lists in Body and Comments let combinedText = issueData.body || ''; if (issueData.comments) { for (const commentBody of issueData.comments) { combinedText += '\n' + (commentBody || ''); } } const taskListLinks = extractTaskListLinks( combinedText, current.owner, current.repo, ); for (const link of taskListLinks) { const key = `${link.owner}/${link.repo}#${link.number}`; children.set(key, link); } // Queue children (regardless of which repo they are in, for recursion) for (const [key, child] of children) { if (!visited.has(key)) { visited.add(key); queue.push(child); } } } catch (error) { console.error(`Error processing ${currentKey}: ${error.message}`); } } return Array.from(allDescendants.values()); } async function run() { if (isDryRun) { console.log('=== DRY RUN MODE: No labels will be applied ==='); } const descendants = await getAllDescendants(ROOT_ISSUES); console.log( `\nFound ${descendants.length} total unique open descendant issues in ${PUBLIC_REPO}.`, ); for (const issueInfo of descendants) { const issueKey = `${issueInfo.owner}/${issueInfo.repo}#${issueInfo.number}`; try { // Data is already available from the discovery phase const hasLabel = issueInfo.labels.some((l) => l === TARGET_LABEL); if (!hasLabel) { if (isDryRun) { console.log( `[DRY RUN] Would label ${issueKey}: "${issueInfo.title}"`, ); } else { console.log(`Labeling ${issueKey}: "${issueInfo.title}"...`); await octokit.rest.issues.addLabels({ owner: issueInfo.owner, repo: issueInfo.repo, issue_number: issueInfo.number, labels: [TARGET_LABEL], }); } } // Remove status/need-triage from maintainer-only issues since they // don't need community triage. We always attempt removal rather than // checking the (potentially stale) label snapshot, because the // issue-opened-labeler workflow runs concurrently and may add the // label after our snapshot was taken. if (isDryRun) { console.log( `[DRY RUN] Would remove status/need-triage from ${issueKey}`, ); } else { try { await octokit.rest.issues.removeLabel({ owner: issueInfo.owner, repo: issueInfo.repo, issue_number: issueInfo.number, name: 'status/need-triage', }); console.log(`Removed status/need-triage from ${issueKey}`); } catch (removeError) { // 404 means the label wasn't present — that's fine. if (removeError.status === 404) { console.log( `status/need-triage not present on ${issueKey}, skipping.`, ); } else { throw removeError; } } } } catch (error) { console.error(`Error processing label for ${issueKey}: ${error.message}`); } } } run().catch((error) => { console.error(error); process.exit(1); }); ================================================ FILE: .github/workflows/chained_e2e.yml ================================================ name: 'Testing: E2E (Chained)' on: push: branches: - 'main' merge_group: workflow_run: workflows: ['Trigger E2E'] types: ['completed'] workflow_dispatch: inputs: head_sha: description: 'SHA of the commit to test' required: true repo_name: description: 'Repository name (e.g., owner/repo)' required: true concurrency: group: '${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.ref }}' cancel-in-progress: |- ${{ github.event_name != 'push' && github.event_name != 'merge_group' }} permissions: contents: 'read' statuses: 'write' jobs: merge_queue_skipper: name: 'Merge Queue Skipper' permissions: 'read-all' runs-on: 'gemini-cli-ubuntu-16-core' if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}' steps: - id: 'merge-queue-e2e-skipper' uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main with: secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' continue-on-error: true download_repo_name: runs-on: 'gemini-cli-ubuntu-16-core' if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')" outputs: repo_name: '${{ steps.output-repo-name.outputs.repo_name }}' head_sha: '${{ steps.output-repo-name.outputs.head_sha }}' steps: - name: 'Mock Repo Artifact' if: "${{ github.event_name == 'workflow_dispatch' }}" env: REPO_NAME: '${{ github.event.inputs.repo_name }}' run: | mkdir -p ./pr echo "${REPO_NAME}" > ./pr/repo_name - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' path: 'pr/' - name: 'Download the repo_name artifact' uses: 'actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0' # ratchet:actions/download-artifact@v5 env: RUN_ID: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || github.run_id }}" with: github-token: '${{ secrets.GITHUB_TOKEN }}' name: 'repo_name' run-id: '${{ env.RUN_ID }}' path: '${{ runner.temp }}/artifacts' - name: 'Output Repo Name and SHA' id: 'output-repo-name' uses: 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' # ratchet:actions/github-script@v8 with: github-token: '${{ secrets.GITHUB_TOKEN }}' script: | const fs = require('fs'); const path = require('path'); const temp = '${{ runner.temp }}/artifacts'; const repoPath = path.join(temp, 'repo_name'); if (fs.existsSync(repoPath)) { const repo_name = String(fs.readFileSync(repoPath)).trim(); core.setOutput('repo_name', repo_name); } const shaPath = path.join(temp, 'head_sha'); if (fs.existsSync(shaPath)) { const head_sha = String(fs.readFileSync(shaPath)).trim(); core.setOutput('head_sha', head_sha); } parse_run_context: name: 'Parse run context' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'download_repo_name' if: "github.repository == 'google-gemini/gemini-cli' && always()" outputs: repository: '${{ steps.set_context.outputs.REPO }}' sha: '${{ steps.set_context.outputs.SHA }}' steps: - id: 'set_context' name: 'Set dynamic repository and SHA' env: REPO: '${{ needs.download_repo_name.outputs.repo_name || github.repository }}' SHA: '${{ needs.download_repo_name.outputs.head_sha || github.event.inputs.head_sha || github.event.workflow_run.head_sha || github.sha }}' shell: 'bash' run: | echo "REPO=$REPO" >> "$GITHUB_OUTPUT" echo "SHA=$SHA" >> "$GITHUB_OUTPUT" set_pending_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' needs: - 'parse_run_context' if: "github.repository == 'google-gemini/gemini-cli' && always()" steps: - name: 'Set pending status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' sha: '${{ needs.parse_run_context.outputs.sha }}' token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' status: 'pending' context: 'E2E (Chained)' e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' needs: - 'merge_queue_skipper' - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') strategy: fail-fast: false matrix: sandbox: - 'sandbox:none' - 'sandbox:docker' node-version: - '20.x' steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' - name: 'Set up Node.js ${{ matrix.node-version }}' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '${{ matrix.node-version }}' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Set up Docker' if: "${{matrix.sandbox == 'sandbox:docker'}}" uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' VERBOSE: 'true' BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' shell: 'bash' run: | if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then npm run test:integration:sandbox:docker else npm run test:integration:sandbox:none fi e2e_mac: name: 'E2E Test (macOS)' needs: - 'merge_queue_skipper' - 'parse_run_context' runs-on: 'macos-latest' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '20.x' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Fix rollup optional dependencies on macOS' if: "${{runner.os == 'macOS'}}" run: | npm cache clean --force - name: 'Run E2E tests (non-Windows)' if: "${{runner.os != 'Windows'}}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' run: 'npm run test:integration:sandbox:none' e2e_windows: name: 'Slow E2E - Win' needs: - 'merge_queue_skipper' - 'parse_run_context' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') runs-on: 'gemini-cli-windows-16-core' steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '20.x' cache: 'npm' - name: 'Configure Windows Defender exclusions' run: | Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force Add-MpPreference -ExclusionPath "$env:TEMP" -Force shell: 'pwsh' - name: 'Configure npm for Windows performance' run: | npm config set progress false npm config set audit false npm config set fund false npm config set loglevel error npm config set maxsockets 32 npm config set registry https://registry.npmjs.org/ shell: 'pwsh' - name: 'Install dependencies' run: 'npm ci' shell: 'pwsh' - name: 'Build project' run: 'npm run build' shell: 'pwsh' - name: 'Ensure Chrome is available' shell: 'pwsh' run: | $chromePaths = @( "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" ) $chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $chromeExists) { Write-Host 'Chrome not found, installing via Chocolatey...' choco install googlechrome -y --no-progress --ignore-checksums } $installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($installed) { Write-Host "Chrome found at: $installed" & $installed --version } else { Write-Error 'Chrome installation failed' exit 1 } - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' shell: 'pwsh' run: 'npm run test:integration:sandbox:none' evals: name: 'Evals (ALWAYS_PASSING)' needs: - 'merge_queue_skipper' - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' fetch-depth: 0 - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '20.x' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Check if evals should run' id: 'check_evals' run: | SHOULD_RUN=$(node scripts/changed_prompt.js) echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - name: 'Run Evals (Required to pass)' if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: 'npm run test:always_passing_evals' e2e: name: 'E2E' if: | github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') needs: - 'e2e_linux' - 'e2e_mac' - 'e2e_windows' - 'evals' - 'merge_queue_skipper' runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Check E2E test results' run: | if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ ${NEEDS_E2E_MAC_RESULT} != 'success' || \ ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \ ${NEEDS_EVALS_RESULT} != 'success' ]]; then echo "One or more E2E jobs failed." exit 1 fi echo "All required E2E jobs passed!" env: NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' NEEDS_EVALS_RESULT: '${{ needs.evals.result }}' set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'parse_run_context' - 'e2e' steps: - name: 'Set workflow status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' sha: '${{ needs.parse_run_context.outputs.sha }}' token: '${{ secrets.GITHUB_TOKEN }}' status: '${{ needs.e2e.result }}' context: 'E2E (Chained)' ================================================ FILE: .github/workflows/ci.yml ================================================ name: 'Testing: CI' on: push: branches: - 'main' - 'release/**' pull_request: branches: - 'main' - 'release/**' merge_group: workflow_dispatch: inputs: branch_ref: description: 'Branch to run on' required: true default: 'main' type: 'string' concurrency: group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' cancel-in-progress: |- ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} permissions: checks: 'write' contents: 'read' statuses: 'write' defaults: run: shell: 'bash' jobs: merge_queue_skipper: permissions: 'read-all' name: 'Merge Queue Skipper' runs-on: 'gemini-cli-ubuntu-16-core' if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}' steps: - id: 'merge-queue-ci-skipper' uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main with: secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' lint: name: 'Lint' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" env: GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.inputs.branch_ref || github.ref }}' fetch-depth: 0 - name: 'Set up Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Cache Linters' uses: 'actions/cache@v4' with: path: '${{ env.GEMINI_LINT_TEMP_DIR }}' key: "${{ runner.os }}-${{ runner.arch }}-linters-${{ hashFiles('scripts/lint.js') }}" - name: 'Install dependencies' run: 'npm ci' - name: 'Cache ESLint' uses: 'actions/cache@v4' with: path: '.eslintcache' key: "${{ runner.os }}-eslint-${{ hashFiles('package-lock.json', 'eslint.config.js') }}" - name: 'Validate NOTICES.txt' run: 'git diff --exit-code packages/vscode-ide-companion/NOTICES.txt' - name: 'Check lockfile' run: 'npm run check:lockfile' - name: 'Install linters' run: 'node scripts/lint.js --setup' - name: 'Run ESLint' run: 'node scripts/lint.js --eslint' - name: 'Run actionlint' run: 'node scripts/lint.js --actionlint' - name: 'Run shellcheck' run: 'node scripts/lint.js --shellcheck' - name: 'Run yamllint' run: 'node scripts/lint.js --yamllint' - name: 'Run Prettier' run: 'node scripts/lint.js --prettier' - name: 'Build docs prerequisites' run: 'npm run predocs:settings' - name: 'Verify settings docs' run: 'npm run docs:settings -- --check' - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' link_checker: name: 'Link Checker' runs-on: 'ubuntu-latest' if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Link Checker' uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 with: args: '--verbose --accept 200,503 ./**/*.md' fail: true test_linux: name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}' runs-on: 'gemini-cli-ubuntu-16-core' needs: - 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' pull-requests: 'write' strategy: matrix: node-version: - '20.x' - '22.x' - '24.x' shard: - 'cli' - 'others' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Set up Node.js ${{ matrix.node-version }}' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '${{ matrix.node-version }}' cache: 'npm' - name: 'Build project' run: 'npm run build' - name: 'Install dependencies for testing' run: 'npm ci' - name: 'Run tests and generate reports' env: NO_COLOR: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace @google/gemini-cli else # Explicitly list non-cli packages to ensure they are sharded correctly npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false npm run test:scripts fi - name: 'Bundle' run: 'npm run bundle' - name: 'Smoke test bundle' run: 'node ./bundle/gemini.js --version' - name: 'Smoke test npx installation' run: | # 1. Package the project into a tarball TARBALL=$(npm pack | tail -n 1) # 2. Move to a fresh directory for isolation mkdir -p ../smoke-test-dir mv "$TARBALL" ../smoke-test-dir/ cd ../smoke-test-dir # 3. Run npx from the tarball npx "./$TARBALL" --version - name: 'Wait for file system sync' run: 'sleep 2' - name: 'Publish Test Report (for non-forks)' if: |- ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 with: name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' path: 'packages/*/junit.xml' reporter: 'java-junit' fail-on-error: 'false' - name: 'Upload Test Results Artifact (for forks)' if: |- ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/junit.xml' test_mac: name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}' runs-on: 'macos-latest' needs: - 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' pull-requests: 'write' continue-on-error: true strategy: matrix: node-version: - '20.x' - '22.x' - '24.x' shard: - 'cli' - 'others' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Set up Node.js ${{ matrix.node-version }}' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '${{ matrix.node-version }}' cache: 'npm' - name: 'Build project' run: 'npm run build' - name: 'Install dependencies for testing' run: 'npm ci' - name: 'Run tests and generate reports' env: NO_COLOR: true run: | if [[ "${{ matrix.shard }}" == "cli" ]]; then npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false else # Explicitly list non-cli packages to ensure they are sharded correctly npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false npm run test:scripts fi - name: 'Bundle' run: 'npm run bundle' - name: 'Smoke test bundle' run: 'node ./bundle/gemini.js --version' - name: 'Smoke test npx installation' run: | # 1. Package the project into a tarball TARBALL=$(npm pack | tail -n 1) # 2. Move to a fresh directory for isolation mkdir -p ../smoke-test-dir mv "$TARBALL" ../smoke-test-dir/ cd ../smoke-test-dir # 3. Run npx from the tarball npx "./$TARBALL" --version - name: 'Wait for file system sync' run: 'sleep 2' - name: 'Publish Test Report (for non-forks)' if: |- ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 with: name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' path: 'packages/*/junit.xml' reporter: 'java-junit' fail-on-error: 'false' - name: 'Upload Test Results Artifact (for forks)' if: |- ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/junit.xml' - name: 'Upload coverage reports' if: |- ${{ always() }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'coverage-reports-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/coverage' codeql: name: 'CodeQL' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: actions: 'read' contents: 'read' security-events: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.inputs.branch_ref || github.ref }}' - name: 'Initialize CodeQL' uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3 with: languages: 'javascript' - name: 'Perform CodeQL Analysis' uses: 'github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/analyze@v3 # Check for changes in bundle size. bundle_size: name: 'Check Bundle Size' needs: 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' # For checkout pull-requests: 'write' # For commenting steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.inputs.branch_ref || github.ref }}' fetch-depth: 1 - uses: 'preactjs/compressed-size-action@946a292cd35bd1088e0d7eb92b69d1a8d5b5d76a' with: repo-token: '${{ secrets.GITHUB_TOKEN }}' pattern: './bundle/**/*.{js,sb}' minimum-change-threshold: '1000' compression: 'none' clean-script: 'clean' test_windows: name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" timeout-minutes: 60 strategy: matrix: shard: - 'cli' - 'others' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.inputs.branch_ref || github.ref }}' - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - name: 'Configure Windows Defender exclusions' run: | Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force Add-MpPreference -ExclusionPath "$env:TEMP" -Force shell: 'pwsh' - name: 'Configure npm for Windows performance' run: | npm config set progress false npm config set audit false npm config set fund false npm config set loglevel error npm config set maxsockets 32 npm config set registry https://registry.npmjs.org/ shell: 'pwsh' - name: 'Install dependencies' run: 'npm ci' shell: 'pwsh' - name: 'Build project' run: 'npm run build' shell: 'pwsh' env: NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'production' - name: 'Run tests and generate reports' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' NO_COLOR: true NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' run: | if ("${{ matrix.shard }}" -eq "cli") { npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false } else { # Explicitly list non-cli packages to ensure they are sharded correctly npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false npm run test:scripts } shell: 'pwsh' - name: 'Bundle' run: 'npm run bundle' shell: 'pwsh' - name: 'Smoke test bundle' run: 'node ./bundle/gemini.js --version' shell: 'pwsh' - name: 'Smoke test npx installation' run: | # 1. Package the project into a tarball $PACK_OUTPUT = npm pack $TARBALL = $PACK_OUTPUT[-1] # 2. Move to a fresh directory for isolation New-Item -ItemType Directory -Force -Path ../smoke-test-dir Move-Item $TARBALL ../smoke-test-dir/ Set-Location ../smoke-test-dir # 3. Run npx from the tarball npx "./$TARBALL" --version shell: 'pwsh' ci: name: 'CI' if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'lint' - 'link_checker' - 'test_linux' - 'test_mac' - 'test_windows' - 'codeql' - 'bundle_size' runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Check all job results' run: | if [[ (${NEEDS_LINT_RESULT} != 'success' && ${NEEDS_LINT_RESULT} != 'skipped') || \ (${NEEDS_LINK_CHECKER_RESULT} != 'success' && ${NEEDS_LINK_CHECKER_RESULT} != 'skipped') || \ (${NEEDS_TEST_LINUX_RESULT} != 'success' && ${NEEDS_TEST_LINUX_RESULT} != 'skipped') || \ (${NEEDS_TEST_MAC_RESULT} != 'success' && ${NEEDS_TEST_MAC_RESULT} != 'skipped') || \ (${NEEDS_TEST_WINDOWS_RESULT} != 'success' && ${NEEDS_TEST_WINDOWS_RESULT} != 'skipped') || \ (${NEEDS_CODEQL_RESULT} != 'success' && ${NEEDS_CODEQL_RESULT} != 'skipped') || \ (${NEEDS_BUNDLE_SIZE_RESULT} != 'success' && ${NEEDS_BUNDLE_SIZE_RESULT} != 'skipped') ]]; then echo "One or more CI jobs failed." exit 1 fi echo "All CI jobs passed!" env: NEEDS_LINT_RESULT: '${{ needs.lint.result }}' NEEDS_LINK_CHECKER_RESULT: '${{ needs.link_checker.result }}' NEEDS_TEST_LINUX_RESULT: '${{ needs.test_linux.result }}' NEEDS_TEST_MAC_RESULT: '${{ needs.test_mac.result }}' NEEDS_TEST_WINDOWS_RESULT: '${{ needs.test_windows.result }}' NEEDS_CODEQL_RESULT: '${{ needs.codeql.result }}' NEEDS_BUNDLE_SIZE_RESULT: '${{ needs.bundle_size.result }}' ================================================ FILE: .github/workflows/community-report.yml ================================================ name: 'Generate Weekly Community Report 📊' on: schedule: - cron: '0 12 * * 1' # Run at 12:00 UTC on Monday workflow_dispatch: inputs: days: description: 'Number of days to look back for the report' required: true default: '7' jobs: generate-report: name: 'Generate Report 📝' if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} runs-on: 'ubuntu-latest' permissions: issues: 'write' pull-requests: 'read' discussions: 'read' contents: 'read' id-token: 'write' steps: - name: 'Generate GitHub App Token 🔑' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' permission-pull-requests: 'read' permission-discussions: 'read' permission-contents: 'read' - name: 'Generate Report 📜' id: 'report' env: GH_TOKEN: '${{ steps.generate_token.outputs.token }}' REPO: '${{ github.repository }}' DAYS: '${{ github.event.inputs.days || 7 }}' run: |- set -e START_DATE="$(date -u -d "$DAYS days ago" +'%Y-%m-%d')" END_DATE="$(date -u +'%Y-%m-%d')" echo "⏳ Generating report for contributions from ${START_DATE} to ${END_DATE}..." declare -A author_is_googler check_googler_status() { local author="$1" if [[ "${author}" == *"[bot]" ]]; then author_is_googler[${author}]=1 return 1 fi if [[ -v "author_is_googler[${author}]" ]]; then return "${author_is_googler[${author}]}" fi if gh api "orgs/googlers/members/${author}" --silent 2>/dev/null; then echo "🧑‍💻 ${author} is a Googler." author_is_googler[${author}]=0 else echo "🌍 ${author} is a community contributor." author_is_googler[${author}]=1 fi return "${author_is_googler[${author}]}" } googler_issues=0 non_googler_issues=0 googler_prs=0 non_googler_prs=0 echo "🔎 Fetching issues and pull requests..." ITEMS_JSON="$(gh search issues --repo "${REPO}" "created:>${START_DATE}" --json author,isPullRequest --limit 1000)" for row in $(echo "${ITEMS_JSON}" | jq -r '.[] | @base64'); do _jq() { echo "${row}" | base64 --decode | jq -r "${1}" } author="$(_jq '.author.login')" is_pr="$(_jq '.isPullRequest')" if [[ -z "${author}" || "${author}" == "null" ]]; then continue fi if check_googler_status "${author}"; then if [[ "${is_pr}" == "true" ]]; then ((googler_prs++)) else ((googler_issues++)) fi else if [[ "${is_pr}" == "true" ]]; then ((non_googler_prs++)) else ((non_googler_issues++)) fi fi done googler_discussions=0 non_googler_discussions=0 echo "🗣️ Fetching discussions..." DISCUSSION_QUERY=''' query($q: String!) { search(query: $q, type: DISCUSSION, first: 100) { nodes { ... on Discussion { author { login } } } } }''' DISCUSSIONS_JSON="$(gh api graphql -f q="repo:${REPO} created:>${START_DATE}" -f query="${DISCUSSION_QUERY}")" for row in $(echo "${DISCUSSIONS_JSON}" | jq -r '.data.search.nodes[] | @base64'); do _jq() { echo "${row}" | base64 --decode | jq -r "${1}" } author="$(_jq '.author.login')" if [[ -z "${author}" || "${author}" == "null" ]]; then continue fi if check_googler_status "${author}"; then ((googler_discussions++)) else ((non_googler_discussions++)) fi done echo "✍️ Generating report content..." TOTAL_ISSUES=$((googler_issues + non_googler_issues)) TOTAL_PRS=$((googler_prs + non_googler_prs)) TOTAL_DISCUSSIONS=$((googler_discussions + non_googler_discussions)) REPORT_BODY=$(cat <> "${GITHUB_OUTPUT}" echo "${REPORT_BODY}" >> "${GITHUB_OUTPUT}" echo "EOF" >> "${GITHUB_OUTPUT}" echo "📊 Community Contribution Report:" echo "${REPORT_BODY}" - name: '🤖 Get Insights from Report' if: |- ${{ steps.report.outputs.report_body != '' }} uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' REPOSITORY: '${{ github.repository }}' with: gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { "coreTools": [ "run_shell_command(gh issue list)", "run_shell_command(gh pr list)", "run_shell_command(gh search issues)", "run_shell_command(gh search prs)" ] } prompt: |- You are a helpful assistant that analyzes community contribution reports. Based on the following report, please provide a brief summary and highlight any interesting trends or potential areas for improvement. Report: ${{ steps.report.outputs.report_body }} ================================================ FILE: .github/workflows/deflake.yml ================================================ name: 'Deflake E2E' on: workflow_dispatch: inputs: branch_ref: description: 'Branch to run on' required: true default: 'main' type: 'string' test_name_pattern: description: 'The test name pattern to use' required: false type: 'string' runs: description: 'The number of runs' required: false default: 5 type: 'number' concurrency: group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' cancel-in-progress: |- ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} jobs: deflake_e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' runs-on: 'gemini-cli-ubuntu-16-core' if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: sandbox: - 'sandbox:none' - 'sandbox:docker' node-version: - '20.x' steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.pull_request.head.sha }}' repository: '${{ github.repository }}' - name: 'Set up Node.js ${{ matrix.node-version }}' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '${{ matrix.node-version }}' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Set up Docker' if: "matrix.sandbox == 'sandbox:docker'" uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}" KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' VERBOSE: 'true' shell: 'bash' run: | if [[ "${IS_DOCKER}" == "true" ]]; then npm run deflake:test:integration:sandbox:docker -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" else npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" fi deflake_e2e_mac: name: 'E2E Test (macOS)' runs-on: 'macos-latest' if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.pull_request.head.sha }}' repository: '${{ github.repository }}' - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '20.x' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Fix rollup optional dependencies on macOS' if: "runner.os == 'macOS'" run: | npm cache clean --force - name: 'Run E2E tests (non-Windows)' if: "runner.os != 'Windows'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' RUNS: '${{ github.event.inputs.runs }}' SANDBOX: 'sandbox:none' TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' VERBOSE: 'true' run: | npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" deflake_e2e_windows: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.pull_request.head.sha }}' repository: '${{ github.repository }}' - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 with: node-version: '20.x' cache: 'npm' - name: 'Configure Windows Defender exclusions' run: | Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force Add-MpPreference -ExclusionPath "$env:TEMP" -Force shell: 'pwsh' - name: 'Configure npm for Windows performance' run: | npm config set progress false npm config set audit false npm config set fund false npm config set loglevel error npm config set maxsockets 32 npm config set registry https://registry.npmjs.org/ shell: 'pwsh' - name: 'Install dependencies' run: 'npm ci' shell: 'pwsh' - name: 'Build project' run: 'npm run build' shell: 'pwsh' - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' SANDBOX: 'sandbox:none' VERBOSE: 'true' NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' UV_THREADPOOL_SIZE: '32' NODE_ENV: 'test' RUNS: '${{ github.event.inputs.runs }}' TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' shell: 'pwsh' run: | npm run deflake:test:integration:sandbox:none -- --runs="$env:RUNS" -- --testNamePattern "'$env:TEST_NAME_PATTERN'" ================================================ FILE: .github/workflows/docs-page-action.yml ================================================ name: 'Deploy GitHub Pages' on: push: tags: 'v*' workflow_dispatch: permissions: contents: 'read' pages: 'write' id-token: 'write' # Allow only one concurrent deployment, skipping runs queued between the run # in-progress and latest queued. However, do NOT cancel in-progress runs as we # want to allow these production deployments to complete. concurrency: group: '${{ github.workflow }}' cancel-in-progress: false jobs: build: if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Setup Pages' uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5 - name: 'Build with Jekyll' uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1 with: source: './' destination: './_site' - name: 'Upload artifact' uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 deploy: if: "github.repository == 'google-gemini/gemini-cli'" environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' runs-on: 'ubuntu-latest' needs: 'build' steps: - name: 'Deploy to GitHub Pages' id: 'deployment' uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4 ================================================ FILE: .github/workflows/docs-rebuild.yml ================================================ name: 'Trigger Docs Rebuild' on: push: branches: - 'main' paths: - 'docs/**' jobs: trigger-rebuild: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' steps: - name: 'Trigger rebuild' run: | curl -X POST \ -H "Content-Type: application/json" \ -d '{}' \ "${{ secrets.DOCS_REBUILD_URL }}" ================================================ FILE: .github/workflows/eval.yml ================================================ name: 'Eval' on: workflow_dispatch: defaults: run: shell: 'bash' permissions: contents: 'read' id-token: 'write' packages: 'read' jobs: eval: name: 'Eval' if: >- github.repository == 'google-gemini/gemini-cli' runs-on: 'ubuntu-latest' container: image: 'ghcr.io/google-gemini/gemini-cli-swe-agent-eval@sha256:cd5edc4afd2245c1f575e791c0859b3c084a86bb3bd9a6762296da5162b35a8f' credentials: username: '${{ github.actor }}' password: '${{ secrets.GITHUB_TOKEN }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' DEFAULT_VERTEXAI_PROJECT: '${{ vars.GOOGLE_CLOUD_PROJECT }}' GOOGLE_CLOUD_PROJECT: '${{ vars.GOOGLE_CLOUD_PROJECT }}' GEMINI_API_KEY: '${{ secrets.EVAL_GEMINI_API_KEY }}' GCLI_LOCAL_FILE_TELEMETRY: 'True' EVAL_GCS_BUCKET: '${{ vars.EVAL_GCS_ARTIFACTS_BUCKET }}' steps: - name: 'Authenticate to Google Cloud' id: 'auth' uses: 'google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed' # ratchet:exclude pin@v2.1.7 with: project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' token_format: 'access_token' access_token_scopes: 'https://www.googleapis.com/auth/cloud-platform' - name: 'Run evaluation' working-directory: '/app' run: | poetry run exp_run --experiment-mode=on-demand --branch-or-commit="${GITHUB_REF_NAME}" --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 poetry run python agent_prototypes/scripts/parse_gcli_logs_experiment.py --experiment_dir=experiments/adhoc/gcli_temp_exp --gcs-bucket="${EVAL_GCS_BUCKET}" --gcs-path=gh_action_artifacts ================================================ FILE: .github/workflows/evals-nightly.yml ================================================ name: 'Evals: Nightly' on: schedule: - cron: '0 1 * * *' # Runs at 1 AM every day workflow_dispatch: inputs: run_all: description: 'Run all evaluations (including usually passing)' type: 'boolean' default: true test_name_pattern: description: 'Test name pattern or file name' required: false type: 'string' permissions: contents: 'read' checks: 'write' actions: 'read' jobs: evals: name: 'Evals (USUALLY_PASSING) nightly run' runs-on: 'gemini-cli-ubuntu-16-core' if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: model: - 'gemini-3.1-pro-preview-customtools' - 'gemini-3-pro-preview' - 'gemini-3-flash-preview' - 'gemini-2.5-pro' - 'gemini-2.5-flash' - 'gemini-2.5-flash-lite' run_attempt: [1, 2, 3] steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Set up Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install dependencies' run: 'npm ci' - name: 'Build project' run: 'npm run build' - name: 'Create logs directory' run: 'mkdir -p evals/logs' - name: 'Run Evals' continue-on-error: true env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_MODEL: '${{ matrix.model }}' RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' run: | CMD="npm run test:all_evals" PATTERN="${TEST_NAME_PATTERN}" if [[ -n "$PATTERN" ]]; then if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then $CMD -- "$PATTERN" else $CMD -- -t "$PATTERN" fi else $CMD fi - name: 'Upload Logs' if: 'always()' uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'eval-logs-${{ matrix.model }}-${{ matrix.run_attempt }}' path: 'evals/logs' retention-days: 7 aggregate-results: name: 'Aggregate Results' needs: ['evals'] if: "github.repository == 'google-gemini/gemini-cli' && always()" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Download Logs' uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4 with: path: 'artifacts' - name: 'Generate Summary' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: 'node scripts/aggregate_evals.js artifacts >> "$GITHUB_STEP_SUMMARY"' ================================================ FILE: .github/workflows/gemini-automated-issue-dedup.yml ================================================ name: '🏷️ Gemini Automated Issue Deduplication' on: issues: types: - 'opened' - 'reopened' issue_comment: types: - 'created' workflow_dispatch: inputs: issue_number: description: 'issue number to dedup' required: true type: 'number' concurrency: group: '${{ github.workflow }}-${{ github.event.issue.number }}' cancel-in-progress: true defaults: run: shell: 'bash' jobs: find-duplicates: if: |- github.repository == 'google-gemini/gemini-cli' && vars.TRIAGE_DEDUPLICATE_ISSUES != '' && (github.event_name == 'issues' || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@gemini-cli /deduplicate') && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR'))) permissions: contents: 'read' id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings issues: 'read' statuses: 'read' packages: 'read' timeout-minutes: 20 runs-on: 'ubuntu-latest' outputs: duplicate_issues_csv: '${{ env.DUPLICATE_ISSUES_CSV }}' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Log in to GitHub Container Registry' uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 with: registry: 'ghcr.io' username: '${{ github.actor }}' password: '${{ secrets.GITHUB_TOKEN }}' - name: 'Find Duplicate Issues' uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_issue_deduplication' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ISSUE_TITLE: '${{ github.event.issue.title }}' ISSUE_BODY: '${{ github.event.issue.body }}' ISSUE_NUMBER: '${{ github.event.issue.number }}' REPOSITORY: '${{ github.repository }}' FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' with: gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { "mcpServers": { "issue_deduplication": { "command": "docker", "args": [ "run", "-i", "--rm", "--network", "host", "-e", "GITHUB_TOKEN", "-e", "GEMINI_API_KEY", "-e", "DATABASE_TYPE", "-e", "FIRESTORE_DATABASE_ID", "-e", "GCP_PROJECT", "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" ], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}", "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", "DATABASE_TYPE":"firestore", "GCP_PROJECT": "${FIRESTORE_PROJECT}", "FIRESTORE_DATABASE_ID": "(default)", "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" }, "timeout": 600000 } }, "maxSessionTurns": 25, "coreTools": [ "run_shell_command(echo)", "run_shell_command(gh issue view)" ], "telemetry": { "enabled": true, "target": "gcp" } } prompt: |- ## Role You are an issue de-duplication assistant. Your goal is to find duplicate issues for a given issue. ## Steps 1. **Find Potential Duplicates:** - The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}. - Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter. - If no duplicates are found, you are done. - Print the JSON output from the `duplicates` tool to the logs. 2. **Refine Duplicates List (if necessary):** - If the `duplicates` tool returns between 1 and 14 results, you must refine the list. - For each potential duplicate issue, run `gh issue view --json title,body,comments` to fetch its content. - Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`. - Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates. - It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates. - Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates. - If your final list is empty, you are done. - Print to the logs if you omitted any potential duplicates based on your analysis. - If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step. 3. **Output final duplicates list as CSV:** - Convert the list of appropriate duplicate issue numbers into a comma-separated list (CSV). If there are no appropriate duplicates, use the empty string. - Use the "echo" shell command to append the CSV of issue numbers into the filepath referenced by the environment variable "${GITHUB_ENV}": echo "DUPLICATE_ISSUES_CSV=[DUPLICATE_ISSUES_AS_CSV]" >> "${GITHUB_ENV}" ## Guidelines - Only use the `duplicates` and `run_shell_command` tools. - The `run_shell_command` tool can be used with `gh issue view`. - Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this. - Do not modify the issue content or status. - Do not add comments or labels. - Reference all shell variables as "${VAR}" (with quotes and braces). add-comment-and-label: needs: 'find-duplicates' if: |- github.repository == 'google-gemini/gemini-cli' && vars.TRIAGE_DEDUPLICATE_ISSUES != '' && needs.find-duplicates.outputs.duplicate_issues_csv != '' && ( github.event_name == 'issues' || github.event_name == 'workflow_dispatch' || ( github.event_name == 'issue_comment' && contains(github.event.comment.body, '@gemini-cli /deduplicate') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' ) ) ) permissions: issues: 'write' timeout-minutes: 5 runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' - name: 'Comment and Label Duplicate Issue' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' env: DUPLICATES_OUTPUT: '${{ needs.find-duplicates.outputs.duplicate_issues_csv }}' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- const rawCsv = process.env.DUPLICATES_OUTPUT; core.info(`Raw duplicates CSV: ${rawCsv}`); const duplicateIssues = rawCsv.split(',').map(s => s.trim()).filter(s => s); if (duplicateIssues.length === 0) { core.info('No duplicate issues found. Nothing to do.'); return; } const issueNumber = ${{ github.event.issue.number }}; function formatCommentBody(issues, updated = false) { const header = updated ? 'Found possible duplicate issues (updated):' : 'Found possible duplicate issues:'; const issuesList = issues.map(num => `- #${num}`).join('\n'); const footer = 'If you believe this is not a duplicate, please remove the `status/possible-duplicate` label.'; const magicComment = ''; return `${header}\n\n${issuesList}\n\n${footer}\n${magicComment}`; } const newCommentBody = formatCommentBody(duplicateIssues); const newUpdatedCommentBody = formatCommentBody(duplicateIssues, true); const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, }); const magicComment = ''; const existingComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes(magicComment) ); let commentMade = false; if (existingComment) { // To check if lists are same, just compare the formatted bodies without headers. const existingBodyForCompare = existingComment.body.substring(existingComment.body.indexOf('- #')); const newBodyForCompare = newCommentBody.substring(newCommentBody.indexOf('- #')); if (existingBodyForCompare.trim() !== newBodyForCompare.trim()) { core.info(`Updating existing comment ${existingComment.id}`); await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existingComment.id, body: newUpdatedCommentBody, }); commentMade = true; } else { core.info('Existing comment is up-to-date. Nothing to do.'); } } else { core.info('Creating new comment.'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: newCommentBody, }); commentMade = true; } if (commentMade) { core.info('Adding "status/possible-duplicate" label.'); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: ['status/possible-duplicate'], }); } ================================================ FILE: .github/workflows/gemini-automated-issue-triage.yml ================================================ name: '🏷️ Gemini Automated Issue Triage' on: issues: types: - 'opened' - 'reopened' issue_comment: types: - 'created' workflow_dispatch: inputs: issue_number: description: 'issue number to triage' required: true type: 'number' workflow_call: inputs: issue_number: description: 'issue number to triage' required: false type: 'string' concurrency: group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || inputs.issue_number }}' cancel-in-progress: true defaults: run: shell: 'bash' permissions: contents: 'read' id-token: 'write' issues: 'write' statuses: 'write' packages: 'read' actions: 'write' # Required for cancelling a workflow run jobs: triage-issue: if: |- (github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') && ( github.event_name == 'workflow_dispatch' || ( (github.event_name == 'issues' || github.event_name == 'issue_comment') && (github.event_name != 'issue_comment' || ( contains(github.event.comment.body, '@gemini-cli /triage') && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') )) ) ) && !contains(github.event.issue.labels.*.name, 'area/') timeout-minutes: 5 runs-on: 'ubuntu-latest' steps: - name: 'Get issue data for manual trigger' id: 'get_issue_data' if: |- github.event_name == 'workflow_dispatch' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ secrets.GITHUB_TOKEN }}' script: | const issueNumber = ${{ github.event.inputs.issue_number || inputs.issue_number }}; const { data: issue } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, }); core.setOutput('title', issue.title); core.setOutput('body', issue.body); core.setOutput('labels', issue.labels.map(label => label.name).join(',')); return issue; - name: 'Manual Trigger Pre-flight Checks' if: |- github.event_name == 'workflow_dispatch' env: ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number || inputs.issue_number }}' LABELS: '${{ steps.get_issue_data.outputs.labels }}' run: | if echo "${LABELS}" | grep -q 'area/'; then echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow." exit 1 fi echo "Manual triage checks passed." - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Generate GitHub App Token' id: 'generate_token' env: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' - name: 'Get Repository Labels' id: 'get_labels' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- const { data: labels } = await github.rest.issues.listLabelsForRepo({ owner: context.repo.owner, repo: context.repo.repo, }); const allowedLabels = [ 'area/agent', 'area/enterprise', 'area/non-interactive', 'area/core', 'area/security', 'area/platform', 'area/extensions', 'area/documentation', 'area/unknown' ]; const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name)); core.setOutput('available_labels', labelNames.join(',')); core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); return labelNames; - name: 'Run Gemini Issue Analysis' uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_issue_analysis' env: GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs ISSUE_TITLE: >- ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }} ISSUE_BODY: >- ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }} ISSUE_NUMBER: >- ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }} REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' with: gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { "maxSessionTurns": 25, "telemetry": { "enabled": true, "target": "gcp" }, "coreTools": [ "run_shell_command(echo)" ] } prompt: |- ## Role You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label based on the definitions provided. ## Steps 1. Review the issue title and body: ${{ env.ISSUE_TITLE }} and ${{ env.ISSUE_BODY }}. 2. Review the available labels: ${{ env.AVAILABLE_LABELS }}. 3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions. 4. Fallback Logic: - If you cannot confidently determine the correct area/ label from the definitions, you must use area/unknown. 5. Output your selected label in JSON format and nothing else. Example: {"labels_to_set": ["area/core"]} ## Guidelines - Your output must contain exactly one area/ label. - Triage only the current issue based on its title and body. - Output only valid JSON format. - Do not include any explanation or additional text, just the JSON. Reference 1: Area Definitions area/agent - Description: Issues related to the "brain" of the CLI. This includes the core agent logic, model quality, tool/function calling, and memory. - Example Issues: "I am not getting a reasonable or expected response." "The model is not calling the tool I expected." "The web search tool is not working as expected." "Feature request for a new built-in tool (e.g., read file, write file)." "The generated code is poor quality or incorrect." "The model seems stuck in a loop." "The response from the model is malformed (e.g., broken JSON, bad formatting)." "Concerns about unnecessary token consumption." "Issues with how memory or chat history is managed." "Issues with sub-agents." "Model is switching from one to another unexpectedly." area/enterprise - Description: Issues specific to enterprise-level features, including telemetry, policy, and licenses. - Example Issues: "Usage data is not appearing in our telemetry dashboard." "A user is able to perform an action that should be blocked by an admin policy." "Questions about billing, licensing tiers, or enterprise quotas." area/non-interactive - Description: Issues related to using the CLI in automated or non-interactive environments (headless mode). - Example Issues: "Problems using the CLI as an SDK in another surface." "The CLI is behaving differently when run from a shell script vs. an interactive terminal." "GitHub action is failing." "I am having trouble running the CLI in headless mode" area/core - Description: Issues with the fundamental CLI app itself. This includes the user interface (UI/UX), installation, OS compatibility, and performance. - Example Issues: "I am seeing my screen flicker when using the CLI." "The output in my terminal is malformed or unreadable." "Theme changes are not taking effect." "Keyboard inputs (e.g., arrow keys, Ctrl+C) are not being recognized." "The CLI failed to install or update." "An issue specific to running on Windows, macOS, or Linux." "Problems with command parsing, flags, or argument handling." "High CPU or memory usage by the CLI process." "Issues related to multi-modality (e.g., handling image inputs)." "Problems with the IDE integration connection or installation" area/security - Description: Issues related to user authentication, authorization, data security, and privacy. - Example Issues: "I am unable to sign in." "The login flow is selecting the wrong authentication path" "Problems with API key handling or credential storage." "A report of a security vulnerability" "Concerns about data sanitization or potential data leaks." "Issues or requests related to privacy controls." "Preventing unauthorized data access." area/platform - Description: Issues related to CI/CD, release management, testing, eval infrastructure, capacity, quota management, and sandbox environments. - Example Issues: "I am getting a 429 'Resource Exhausted' or 500-level server error." "General slowness or high latency from the service." "The build script is broken on the main branch." "Tests are failing in the CI/CD pipeline." "Issues with the release management or publishing process." "User is running out of capacity." "Problems specific to the sandbox or staging environments." "Questions about quota limits or requests for increases." area/extensions - Description: Issues related to the extension ecosystem, including the marketplace and website. - Example Issues: "Bugs related to the extension marketplace website." "Issues with a specific extension." "Feature request for the extension ecosystem." area/documentation - Description: Issues related to user-facing documentation and other content on the documentation website. - Example Issues: "A typo in a README file." "DOCS: A command is not working as described in the documentation." "A request for a new documentation page." "Instructions missing for skills feature" area/unknown - Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate. - name: 'Apply Labels to Issue' if: |- ${{ steps.gemini_issue_analysis.outputs.summary != '' }} env: REPOSITORY: '${{ github.repository }}' ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const rawOutput = process.env.LABELS_OUTPUT; core.info(`Raw output from model: ${rawOutput}`); let parsedLabels; try { // First, try to parse the raw output as JSON. parsedLabels = JSON.parse(rawOutput); } catch (jsonError) { // If that fails, check for a markdown code block. core.warning(`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`); const jsonMatch = rawOutput.match(/```json\s*([\s\S]*?)\s*```/); if (jsonMatch && jsonMatch[1]) { try { parsedLabels = JSON.parse(jsonMatch[1].trim()); } catch (markdownError) { core.setFailed(`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawOutput}`); return; } } else { // If no markdown block, try to find a raw JSON object in the output. // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) // before the actual JSON response. const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/); if (jsonObjectMatch) { try { parsedLabels = JSON.parse(jsonObjectMatch[0]); } catch (extractError) { core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`); return; } } else { core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`); return; } } } const issueNumber = parseInt(process.env.ISSUE_NUMBER); const labelsToAdd = parsedLabels.labels_to_set || []; if (labelsToAdd.length !== 1) { core.setFailed(`Expected exactly 1 label (area/), but got ${labelsToAdd.length}. Labels: ${labelsToAdd.join(', ')}`); return; } // Set labels based on triage result await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: labelsToAdd }); core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`); - name: 'Post Issue Analysis Failure Comment' if: |- ${{ failure() && steps.gemini_issue_analysis.outcome == 'failure' }} env: ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parseInt(process.env.ISSUE_NUMBER), body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${process.env.RUN_URL}) for details.' }) ================================================ FILE: .github/workflows/gemini-scheduled-issue-dedup.yml ================================================ name: '📋 Gemini Scheduled Issue Deduplication' on: schedule: - cron: '0 * * * *' # Runs every hour workflow_dispatch: concurrency: group: '${{ github.workflow }}' cancel-in-progress: true defaults: run: shell: 'bash' jobs: refresh-embeddings: if: |- ${{ vars.TRIAGE_DEDUPLICATE_ISSUES != '' && github.repository == 'google-gemini/gemini-cli' }} permissions: contents: 'read' id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings issues: 'read' statuses: 'read' packages: 'read' timeout-minutes: 20 runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Log in to GitHub Container Registry' uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 with: registry: 'ghcr.io' username: '${{ github.actor }}' password: '${{ secrets.GITHUB_TOKEN }}' - name: 'Run Gemini Issue Deduplication Refresh' uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_refresh_embeddings' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ISSUE_TITLE: '${{ github.event.issue.title }}' ISSUE_BODY: '${{ github.event.issue.body }}' ISSUE_NUMBER: '${{ github.event.issue.number }}' REPOSITORY: '${{ github.repository }}' FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' with: gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { "mcpServers": { "issue_deduplication": { "command": "docker", "args": [ "run", "-i", "--rm", "--network", "host", "-e", "GITHUB_TOKEN", "-e", "GEMINI_API_KEY", "-e", "DATABASE_TYPE", "-e", "FIRESTORE_DATABASE_ID", "-e", "GCP_PROJECT", "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" ], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}", "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", "DATABASE_TYPE":"firestore", "GCP_PROJECT": "${FIRESTORE_PROJECT}", "FIRESTORE_DATABASE_ID": "(default)", "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" }, "timeout": 600000 } }, "maxSessionTurns": 25, "coreTools": [ "run_shell_command(echo)" ], "telemetry": { "enabled": true, "target": "gcp" } } prompt: |- ## Role You are a database maintenance assistant for a GitHub issue deduplication system. ## Goal Your sole responsibility is to refresh the embeddings for all open issues in the repository to ensure the deduplication database is up-to-date. ## Steps 1. **Extract Repository Information:** The repository is ${{ github.repository }}. 2. **Refresh Embeddings:** Call the `refresh` tool with the correct `repo`. Do not use the `force` parameter. 3. **Log Output:** Print the JSON output from the `refresh` tool to the logs. ## Guidelines - Only use the `refresh` tool. - Do not attempt to find duplicates or modify any issues. - Your only task is to call the `refresh` tool and log its output. ================================================ FILE: .github/workflows/gemini-scheduled-issue-triage.yml ================================================ name: '📋 Gemini Scheduled Issue Triage' on: issues: types: - 'opened' - 'reopened' schedule: - cron: '0 * * * *' # Runs every hour workflow_dispatch: concurrency: group: '${{ github.workflow }}-${{ github.event.number || github.run_id }}' cancel-in-progress: true defaults: run: shell: 'bash' permissions: id-token: 'write' issues: 'write' jobs: triage-issues: timeout-minutes: 10 if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' - name: 'Get issue from event' if: |- ${{ github.event_name == 'issues' }} id: 'get_issue_from_event' env: ISSUE_EVENT: '${{ toJSON(github.event.issue) }}' run: | set -euo pipefail ISSUE_JSON=$(echo "$ISSUE_EVENT" | jq -c '[{number: .number, title: .title, body: .body}]') echo "issues_to_triage=${ISSUE_JSON}" >> "${GITHUB_OUTPUT}" echo "✅ Found issue #${{ github.event.issue.number }} from event to triage! 🎯" - name: 'Find untriaged issues' if: |- ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} id: 'find_issues' env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' GITHUB_REPOSITORY: '${{ github.repository }}' run: |- set -euo pipefail echo '🔍 Finding issues missing area labels...' NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)" echo '🔍 Finding issues missing kind labels...' NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ --search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)" echo '🏷️ Finding issues missing priority labels...' NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ --search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)" echo '🔄 Merging and deduplicating issues...' ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')" echo '📝 Setting output for GitHub Actions...' echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" ISSUE_COUNT="$(echo "${ISSUES}" | jq 'length')" echo "✅ Found ${ISSUE_COUNT} unique issues to triage! 🎯" - name: 'Get Repository Labels' id: 'get_labels' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' script: |- const { data: labels } = await github.rest.issues.listLabelsForRepo({ owner: context.repo.owner, repo: context.repo.repo, }); const labelNames = labels.map(label => label.name); core.setOutput('available_labels', labelNames.join(',')); core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); return labelNames; - name: 'Run Gemini Issue Analysis' if: |- (steps.get_issue_from_event.outputs.issues_to_triage != '' && steps.get_issue_from_event.outputs.issues_to_triage != '[]') || (steps.find_issues.outputs.issues_to_triage != '' && steps.find_issues.outputs.issues_to_triage != '[]') uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_issue_analysis' env: GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs ISSUES_TO_TRIAGE: '${{ steps.get_issue_from_event.outputs.issues_to_triage || steps.find_issues.outputs.issues_to_triage }}' REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' with: gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' settings: |- { "maxSessionTurns": 25, "coreTools": [ "run_shell_command(echo)" ], "telemetry": { "enabled": true, "target": "gcp" } } prompt: |- ## Role You are an issue triage assistant. Analyze issues and identify appropriate labels. Use the available tools to gather information; do not ask for information to be provided. ## Steps 1. You are only able to use the echo command. Review the available labels in the environment variable: "${AVAILABLE_LABELS}". 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) 3. Review the issue title, body and any comments provided in the environment variables. 4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/* and priority/*. 5. Label Policy: - If the issue already has a kind/ label, do not change it. - If the issue already has a priority/ label, do not change it. - If the issue already has an area/ label, do not change it. - If any of these are missing, select exactly ONE appropriate label for the missing category. 6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc. 7. Give me a single short explanation about why you are selecting each label in the process. 8. Output a JSON array of objects, each containing the issue number and the labels to add and remove, along with an explanation. For example: ``` [ { "issue_number": 123, "labels_to_add": ["area/core", "kind/bug", "priority/p2"], "labels_to_remove": ["status/need-triage"], "explanation": "This issue is a UI bug that needs to be addressed with medium priority." } ] ``` If an issue cannot be classified, do not include it in the output array. 9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 - Anything more than 6 versions older than the most recent should add the status/need-retesting label 10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below. 11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation. 12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label. ## Guidelines - Output only valid JSON format - Do not include any explanation or additional text, just the JSON - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Do not remove the following labels maintainer, help wanted or good first issue. - Triage only the current issue. - Identify only one area/ label. - Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue) - Identify only one priority/ label. - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. Categorization Guidelines (Priority): P0 - Urgent Blocking Issues: - DO NOT APPLY THIS LABEL AUTOMATICALLY. Use status/manual-triage instead. - Definition: Urgent, block a significant percentage of the user base, and prevent frequent use of the Gemini CLI. - This includes core stability blockers (e.g., authentication failures, broken upgrades), critical crashes, and P0 security vulnerabilities. - Impact: Blocks development or testing for the entire team; Major security vulnerability; Causes data loss or corruption with no workaround; Crashes the application or makes a core feature completely unusable for all or most users. - Qualifier: Is the main function of the software broken? P1 - High-Impact Issues: - Definition: Affect a large number of users, blocking them from using parts of the Gemini CLI, or make the CLI frequently unusable even with workarounds available. - Impact: A core feature is broken or behaving incorrectly for a large number of users or use cases; Severe performance degradation; No straightforward workaround exists. - Qualifier: Is a key feature unusable or giving very wrong results? P2 - Significant Issues: - Definition: Affect some users significantly, such as preventing the use of certain features or authentication types. - Can also be issues that many users complain about, causing annoyance or hindering daily use. - Impact: Affects a non-critical feature or a smaller, specific subset of users; An inconvenient but functional workaround is available; Noticeable UI/UX problems that look unprofessional. - Qualifier: Is it an annoying but non-blocking problem? P3 - Low-Impact Issues: - Definition: Typically usability issues that cause annoyance to a limited user base. - Includes feature requests that could be addressed in the near future and may be suitable for community contributions. - Impact: Minor cosmetic issues; An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. - Qualifier: Is it a "nice-to-fix" issue? Categorization Guidelines (Area): area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality area/core: User Interface, OS Support, Core Functionality area/documentation: End-user and contributor-facing documentation, website-related area/enterprise: Telemetry, Policy, Quota / Licensing area/extensions: Gemini CLI extensions capability area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation area/platform: Build infra, Release mgmt, Testing, Eval infra, Capacity, Quota mgmt area/security: security related issues Additional Context: - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue. - This product is designed to use different models eg.. using pro, downgrading to flash etc. - When users report that they dont expect the model to change those would be categorized as feature requests. - name: 'Apply Labels to Issues' if: |- ${{ steps.gemini_issue_analysis.outcome == 'success' && steps.gemini_issue_analysis.outputs.summary != '[]' }} env: REPOSITORY: '${{ github.repository }}' LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' script: |- const rawLabels = process.env.LABELS_OUTPUT; core.info(`Raw labels JSON: ${rawLabels}`); let parsedLabels; try { const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/); if (!jsonMatch || !jsonMatch[1]) { throw new Error("Could not find a ```json ... ``` block in the output."); } const jsonString = jsonMatch[1].trim(); parsedLabels = JSON.parse(jsonString); core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`); } catch (err) { core.setFailed(`Failed to parse labels JSON from Gemini output: ${err.message}\nRaw output: ${rawLabels}`); return; } for (const entry of parsedLabels) { const issueNumber = entry.issue_number; if (!issueNumber) { core.info(`Skipping entry with no issue number: ${JSON.stringify(entry)}`); continue; } const labelsToAdd = entry.labels_to_add || []; labelsToAdd.push('status/bot-triaged'); if (labelsToAdd.length > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: labelsToAdd }); const explanation = entry.explanation ? ` - ${entry.explanation}` : ''; core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`); } if (entry.explanation) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: entry.explanation, }); } if ((!entry.labels_to_add || entry.labels_to_add.length === 0) && (!entry.labels_to_remove || entry.labels_to_remove.length === 0)) { core.info(`No labels to add or remove for #${issueNumber}, leaving as is`); } } ================================================ FILE: .github/workflows/gemini-scheduled-pr-triage.yml ================================================ name: 'Gemini Scheduled PR Triage 🚀' on: schedule: - cron: '*/15 * * * *' # Runs every 15 minutes workflow_dispatch: jobs: audit-prs: timeout-minutes: 15 if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} permissions: contents: 'read' id-token: 'write' issues: 'write' pull-requests: 'write' runs-on: 'ubuntu-latest' outputs: prs_needing_comment: '${{ steps.run_triage.outputs.prs_needing_comment }}' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' permission-pull-requests: 'write' - name: 'Run PR Triage Script' id: 'run_triage' shell: 'bash' env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' GITHUB_REPOSITORY: '${{ github.repository }}' run: |- ./.github/scripts/pr-triage.sh # If prs_needing_comment is empty, set it to [] explicitly for downstream steps if [[ -z "$(grep 'prs_needing_comment' "${GITHUB_OUTPUT}" | cut -d'=' -f2-)" ]]; then echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" fi ================================================ FILE: .github/workflows/gemini-scheduled-stale-issue-closer.yml ================================================ name: '🔒 Gemini Scheduled Stale Issue Closer' on: schedule: - cron: '0 0 * * 0' # Every Sunday at midnight UTC workflow_dispatch: inputs: dry_run: description: 'Run in dry-run mode (no changes applied)' required: false default: false type: 'boolean' concurrency: group: '${{ github.workflow }}' cancel-in-progress: true defaults: run: shell: 'bash' jobs: close-stale-issues: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' steps: - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' - name: 'Process Stale Issues' uses: 'actions/github-script@v7' env: DRY_RUN: '${{ inputs.dry_run }}' with: github-token: '${{ steps.generate_token.outputs.token }}' script: | const dryRun = process.env.DRY_RUN === 'true'; if (dryRun) { core.info('DRY RUN MODE ENABLED: No changes will be applied.'); } const batchLabel = 'Stale'; const threeMonthsAgo = new Date(); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); const tenDaysAgo = new Date(); tenDaysAgo.setDate(tenDaysAgo.getDate() - 10); core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`); core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`); const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`; core.info(`Searching with query: ${query}`); const itemsToCheck = await github.paginate(github.rest.search.issuesAndPullRequests, { q: query, sort: 'created', order: 'asc', per_page: 100 }); core.info(`Found ${itemsToCheck.length} open issues to check.`); let processedCount = 0; for (const issue of itemsToCheck) { const createdAt = new Date(issue.created_at); const updatedAt = new Date(issue.updated_at); const reactionCount = issue.reactions.total_count; // Basic thresholds if (reactionCount >= 5) { continue; } // Skip if it has a maintainer, help wanted, or Public Roadmap label const rawLabels = issue.labels.map((l) => l.name); const lowercaseLabels = rawLabels.map((l) => l.toLowerCase()); if ( lowercaseLabels.some((l) => l.includes('maintainer')) || lowercaseLabels.includes('help wanted') || rawLabels.includes('🗓️ Public Roadmap') ) { continue; } let isStale = updatedAt < tenDaysAgo; // If apparently active, check if it's only bot activity if (!isStale) { try { const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, per_page: 100, sort: 'created', direction: 'desc' }); const lastHumanComment = comments.data.find(comment => comment.user.type !== 'Bot'); if (lastHumanComment) { isStale = new Date(lastHumanComment.created_at) < tenDaysAgo; } else { // No human comments. Check if creator is human. if (issue.user.type !== 'Bot') { isStale = createdAt < tenDaysAgo; } else { isStale = true; // Bot created, only bot comments } } } catch (error) { core.warning(`Failed to fetch comments for issue #${issue.number}: ${error.message}`); continue; } } if (isStale) { processedCount++; const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`; core.info(message); if (!dryRun) { // Add label await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: [batchLabel] }); // Add comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!' }); // Close issue await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, state: 'closed', state_reason: 'not_planned' }); } } } core.info(`\nTotal issues processed: ${processedCount}`); ================================================ FILE: .github/workflows/gemini-scheduled-stale-pr-closer.yml ================================================ name: 'Gemini Scheduled Stale PR Closer' on: schedule: - cron: '0 2 * * *' # Every day at 2 AM UTC pull_request: types: ['opened', 'edited'] workflow_dispatch: inputs: dry_run: description: 'Run in dry-run mode' required: false default: false type: 'boolean' jobs: close-stale-prs: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: pull-requests: 'write' issues: 'write' steps: - name: 'Generate GitHub App Token' id: 'generate_token' env: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Process Stale PRs' uses: 'actions/github-script@v7' env: DRY_RUN: '${{ inputs.dry_run }}' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; const fourteenDaysAgo = new Date(); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); // 1. Fetch maintainers for verification let maintainerLogins = new Set(); const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; for (const team_slug of teams) { try { const members = await github.paginate(github.rest.teams.listMembersInOrg, { org: context.repo.owner, team_slug: team_slug }); for (const m of members) maintainerLogins.add(m.login.toLowerCase()); core.info(`Successfully fetched ${members.length} team members from ${team_slug}`); } catch (e) { // Silently skip if permissions are insufficient; we will rely on author_association core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`); } } const isMaintainer = async (login, assoc) => { // Reliably identify maintainers using authorAssociation (provided by GitHub) // and organization membership (if available). const isTeamMember = maintainerLogins.has(login.toLowerCase()); const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); if (isTeamMember || isRepoMaintainer) return true; // Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission) try { const orgs = ['googlers', 'google']; for (const org of orgs) { try { await github.rest.orgs.checkMembershipForUser({ org: org, username: login }); return true; } catch (e) { if (e.status !== 404) throw e; } } } catch (e) { // Gracefully ignore failures here } return false; }; // 2. Fetch all open PRs let prs = []; if (context.eventName === 'pull_request') { const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number }); prs = [pr]; } else { prs = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 }); } for (const pr of prs) { const maintainerPr = await isMaintainer(pr.user.login, pr.author_association); const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]'); if (maintainerPr || isBot) continue; // Helper: Fetch labels and linked issues via GraphQL const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { closingIssuesReferences(first: 10) { nodes { number labels(first: 20) { nodes { name } } } } } } }`; let linkedIssues = []; try { const res = await github.graphql(prDetailsQuery, { owner: context.repo.owner, repo: context.repo.repo, number: pr.number }); linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes; } catch (e) { core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`); } // Check for mentions in body as fallback (regex) const body = pr.body || ''; const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; const matches = body.match(mentionRegex); if (matches && linkedIssues.length === 0) { const issueNumber = parseInt(matches[1]); try { const { data: issue } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber }); linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }]; } catch (e) {} } // 3. Enforcement Logic const prLabels = pr.labels.map(l => l.name.toLowerCase()); const hasHelpWanted = prLabels.includes('help wanted') || linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted')); const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') || linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only')); const hasLinkedIssue = linkedIssues.length > 0; // Closure Policy: No help-wanted label = Close after 14 days if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) { const prCreatedAt = new Date(pr.created_at); // We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM if (prCreatedAt > fourteenDaysAgo) { core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`); continue; } core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!" }); await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, state: 'closed' }); } continue; } // Also check for linked issue even if it has help wanted (redundant but safe) if (pr.state === 'open' && !hasLinkedIssue) { // Already covered by hasHelpWanted check above, but good for future-proofing continue; } // 4. Staleness Check (Scheduled only) if (pr.state === 'open' && context.eventName !== 'pull_request') { // Skip PRs that were created less than 30 days ago - they cannot be stale yet const prCreatedAt = new Date(pr.created_at); if (prCreatedAt > thirtyDaysAgo) continue; let lastActivity = new Date(pr.created_at); try { const reviews = await github.paginate(github.rest.pulls.listReviews, { owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number }); for (const r of reviews) { if (await isMaintainer(r.user.login, r.author_association)) { const d = new Date(r.submitted_at || r.updated_at); if (d > lastActivity) lastActivity = d; } } const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }); for (const c of comments) { if (await isMaintainer(c.user.login, c.author_association)) { const d = new Date(c.updated_at); if (d > lastActivity) lastActivity = d; } } } catch (e) {} if (lastActivity < thirtyDaysAgo) { const labels = pr.labels.map(l => l.name.toLowerCase()); const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only'); if (isProtected) { core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`); continue; } core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!" }); await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, state: 'closed' }); } } } } ================================================ FILE: .github/workflows/gemini-self-assign-issue.yml ================================================ name: 'Assign Issue on Comment' on: issue_comment: types: - 'created' concurrency: group: '${{ github.workflow }}-${{ github.event.issue.number }}' cancel-in-progress: true defaults: run: shell: 'bash' permissions: contents: 'read' id-token: 'write' issues: 'write' statuses: 'write' packages: 'read' jobs: self-assign-issue: if: |- github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issue_comment' && (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' # Add 'assignments' write permission permission-issues: 'write' - name: 'Assign issue to user' if: "contains(github.event.comment.body, '/assign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' script: | const issueNumber = context.issue.number; const commenter = context.actor; const owner = context.repo.owner; const repo = context.repo.repo; const MAX_ISSUES_ASSIGNED = 3; const issue = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, }); const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted'); if (!hasHelpWantedLabel) { await github.rest.issues.createComment({ owner: owner, repo: repo, issue_number: issueNumber, body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).` }); return; } // Search for open issues already assigned to the commenter in this repo const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({ q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`, advanced_search: true }); if (assignedIssues.total_count >= MAX_ISSUES_ASSIGNED) { await github.rest.issues.createComment({ owner: owner, repo: repo, issue_number: issueNumber, body: `👋 @${commenter}! You currently have ${assignedIssues.total_count} issues assigned to you. We have a ${MAX_ISSUES_ASSIGNED} max issues assigned at once policy. Once you close out an existing issue it will open up space to take another. You can also unassign yourself from an existing issue but please work on a hand-off if someone is expecting work on that issue.` }); return; // exit } if (issue.data.assignees.length > 0) { // Comment that it's already assigned await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `@${commenter} Thanks for taking interest but this issue is already assigned. We'd still love to have you contribute. Check out our [Help Wanted](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22) list for issues where we need some extra attention.` }); return; } // If not taken, assign the user who commented await github.rest.issues.addAssignees({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, assignees: [commenter] }); // Post a comment to confirm assignment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` }); - name: 'Unassign issue from user' if: "contains(github.event.comment.body, '/unassign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' script: | const issueNumber = context.issue.number; const commenter = context.actor; const owner = context.repo.owner; const repo = context.repo.repo; const commentBody = context.payload.comment.body.trim(); if (commentBody !== '/unassign') { return; } const issue = await github.rest.issues.get({ owner: owner, repo: repo, issue_number: issueNumber, }); const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); if (isAssigned) { await github.rest.issues.removeAssignees({ owner: owner, repo: repo, issue_number: issueNumber, assignees: [commenter] }); await github.rest.issues.createComment({ owner: owner, repo: repo, issue_number: issueNumber, body: `👋 @${commenter}, you have been unassigned from this issue.` }); } ================================================ FILE: .github/workflows/issue-opened-labeler.yml ================================================ name: '🏷️ Issue Opened Labeler' on: issues: types: - 'opened' jobs: label-issue: runs-on: 'ubuntu-latest' if: |- ${{ github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' }} steps: - name: 'Generate GitHub App Token' id: 'generate_token' env: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Add need-triage label' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- const { data: issue } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const hasLabel = issue.labels.some(l => l.name === 'status/need-triage'); if (!hasLabel) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ['status/need-triage'] }); } else { core.info('Issue already has status/need-triage label. Skipping.'); } ================================================ FILE: .github/workflows/label-backlog-child-issues.yml ================================================ name: 'Label Child Issues for Project Rollup' on: issues: types: ['opened', 'edited', 'reopened'] schedule: - cron: '0 * * * *' # Run every hour workflow_dispatch: permissions: issues: 'write' contents: 'read' jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@v4' - name: 'Setup Node.js' uses: 'actions/setup-node@v4' with: node-version: '20' cache: 'npm' - name: 'Install Dependencies' run: 'npm ci' - name: 'Run Multi-Repo Sync Script' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: 'node .github/scripts/sync-maintainer-labels.cjs' # Scheduled/Manual: Recursive sync across multiple repos sync-maintainer-labels: if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@v4' - name: 'Setup Node.js' uses: 'actions/setup-node@v4' with: node-version: '20' cache: 'npm' - name: 'Install Dependencies' run: 'npm ci' - name: 'Run Multi-Repo Sync Script' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: 'node .github/scripts/sync-maintainer-labels.cjs' ================================================ FILE: .github/workflows/label-workstream-rollup.yml ================================================ name: 'Label Workstream Rollup' on: issues: types: ['opened', 'edited', 'reopened'] schedule: - cron: '0 * * * *' workflow_dispatch: jobs: labeler: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' steps: - name: 'Check for Parent Workstream and Apply Label' uses: 'actions/github-script@v7' with: script: | const labelToAdd = 'workstream-rollup'; // Allow-list of parent issue URLs const allowedParentUrls = [ 'https://github.com/google-gemini/gemini-cli/issues/15374', 'https://github.com/google-gemini/gemini-cli/issues/15456', 'https://github.com/google-gemini/gemini-cli/issues/15324', 'https://github.com/google-gemini/gemini-cli/issues/17202', 'https://github.com/google-gemini/gemini-cli/issues/17203' ]; // Single issue processing (for event triggers) async function processSingleIssue(owner, repo, number) { const query = ` query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { issue(number:$number) { number parent { url parent { url parent { url parent { url parent { url } } } } } } } } `; try { const result = await github.graphql(query, { owner, repo, number }); if (!result || !result.repository || !result.repository.issue) { console.log(`Issue #${number} not found or data missing.`); return; } const issue = result.repository.issue; await checkAndLabel(issue, owner, repo); } catch (error) { console.error(`Failed to process issue #${number}:`, error); throw error; // Re-throw to be caught by main execution } } // Bulk processing (for schedule/dispatch) async function processAllOpenIssues(owner, repo) { const query = ` query($owner:String!, $repo:String!, $cursor:String) { repository(owner:$owner, name:$repo) { issues(first: 100, states: OPEN, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { number parent { url parent { url parent { url parent { url parent { url } } } } } } } } } `; let hasNextPage = true; let cursor = null; while (hasNextPage) { try { const result = await github.graphql(query, { owner, repo, cursor }); if (!result || !result.repository || !result.repository.issues) { console.error('Invalid response structure from GitHub API'); break; } const issues = result.repository.issues.nodes || []; console.log(`Processing batch of ${issues.length} issues...`); for (const issue of issues) { await checkAndLabel(issue, owner, repo); } hasNextPage = result.repository.issues.pageInfo.hasNextPage; cursor = result.repository.issues.pageInfo.endCursor; } catch (error) { console.error('Failed to fetch issues batch:', error); throw error; // Re-throw to be caught by main execution } } } async function checkAndLabel(issue, owner, repo) { if (!issue || !issue.parent) return; let currentParent = issue.parent; let tracedParents = []; let matched = false; while (currentParent) { tracedParents.push(currentParent.url); if (allowedParentUrls.includes(currentParent.url)) { console.log(`SUCCESS: Issue #${issue.number} is a descendant of ${currentParent.url}. Trace: ${tracedParents.join(' -> ')}. Adding label.`); await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [labelToAdd] }); matched = true; break; } currentParent = currentParent.parent; } if (!matched && context.eventName === 'issues') { console.log(`Issue #${issue.number} did not match any allowed workstreams. Trace: ${tracedParents.join(' -> ') || 'None'}.`); } } // Main execution try { if (context.eventName === 'issues') { console.log(`Processing single issue #${context.payload.issue.number}...`); await processSingleIssue(context.repo.owner, context.repo.repo, context.payload.issue.number); } else { console.log(`Running for event: ${context.eventName}. Processing all open issues...`); await processAllOpenIssues(context.repo.owner, context.repo.repo); } } catch (error) { core.setFailed(`Workflow failed: ${error.message}`); } ================================================ FILE: .github/workflows/links.yml ================================================ name: 'Links' on: push: branches: ['main'] pull_request: branches: ['main'] repository_dispatch: workflow_dispatch: schedule: - cron: '00 18 * * *' jobs: linkChecker: if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} runs-on: 'ubuntu-latest' steps: - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Link Checker' id: 'lychee' uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 with: args: '--verbose --no-progress --accept 200,503 ./**/*.md' ================================================ FILE: .github/workflows/no-response.yml ================================================ name: 'No Response' # Run as a daily cron at 1:45 AM on: schedule: - cron: '45 1 * * *' workflow_dispatch: jobs: no-response: runs-on: 'ubuntu-latest' if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} permissions: issues: 'write' pull-requests: 'write' concurrency: group: '${{ github.workflow }}-no-response' cancel-in-progress: true steps: - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9 with: repo-token: '${{ secrets.GITHUB_TOKEN }}' days-before-stale: -1 days-before-close: 14 stale-issue-label: 'status/need-information' close-issue-message: >- This issue was marked as needing more information and has not received a response in 14 days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you! stale-pr-label: 'status/need-information' close-pr-message: >- This pull request was marked as needing more information and has had no updates in 14 days. Closing it for now. You are welcome to reopen with the required info. Thanks for contributing! ================================================ FILE: .github/workflows/pr-contribution-guidelines-notifier.yml ================================================ name: '🏷️ PR Contribution Guidelines Notifier' on: pull_request: types: - 'opened' jobs: notify-process-change: runs-on: 'ubuntu-latest' if: |- github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' permissions: pull-requests: 'write' steps: - name: 'Generate GitHub App Token' id: 'generate_token' env: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Check membership and post comment' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- const org = context.repo.owner; const repo = context.repo.repo; const username = context.payload.pull_request.user.login; const pr_number = context.payload.pull_request.number; // 1. Check if the PR author is a maintainer // Check team membership (most reliable for private org members) let isTeamMember = false; const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; for (const team_slug of teams) { try { const members = await github.paginate(github.rest.teams.listMembersInOrg, { org: org, team_slug: team_slug }); if (members.some(m => m.login.toLowerCase() === username.toLowerCase())) { isTeamMember = true; core.info(`${username} is a member of ${team_slug}. No notification needed.`); break; } } catch (e) { core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`); } } if (isTeamMember) return; // Check author_association from webhook payload const authorAssociation = context.payload.pull_request.author_association; const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation); if (isRepoMaintainer) { core.info(`${username} is a maintainer (author_association: ${authorAssociation}). No notification needed.`); return; } // Check if author is a Googler const isGoogler = async (login) => { try { const orgs = ['googlers', 'google']; for (const org of orgs) { try { await github.rest.orgs.checkMembershipForUser({ org: org, username: login }); return true; } catch (e) { if (e.status !== 404) throw e; } } } catch (e) { core.warning(`Failed to check org membership for ${login}: ${e.message}`); } return false; }; if (await isGoogler(username)) { core.info(`${username} is a Googler. No notification needed.`); return; } // 2. Check if the PR is already associated with an issue const query = ` query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { closingIssuesReferences(first: 1) { totalCount } } } } `; const variables = { owner: org, repo: repo, number: pr_number }; const result = await github.graphql(query, variables); const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount; if (issueCount > 0) { core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`); return; } // 3. Post the notification comment core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`); const comment = ` Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this. We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706). Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed. Thank you for your understanding and for being a part of our community! `.trim().replace(/^[ ]+/gm, ''); await github.rest.issues.createComment({ owner: org, repo: repo, issue_number: pr_number, body: comment }); ================================================ FILE: .github/workflows/pr-rate-limiter.yaml ================================================ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: 'PR rate limiter' permissions: {} on: pull_request_target: types: - 'opened' - 'reopened' jobs: limit: runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' pull-requests: 'write' steps: - name: 'Limit open pull requests per user' uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master with: except-author-associations: 'MEMBER,OWNER,COLLABORATOR' comment-limit: 8 comment: > You already have 7 pull requests open. Please work on getting existing PRs merged before opening more. close-limit: 8 close: true ================================================ FILE: .github/workflows/release-change-tags.yml ================================================ name: 'Release: Change Tags' on: workflow_dispatch: inputs: version: description: 'The package version to tag (e.g., 0.5.0-preview-2). This version must already exist on the npm registry.' required: true type: 'string' channel: description: 'The npm dist-tag to apply (e.g., latest, preview, nightly).' required: true type: 'choice' options: - 'dev' - 'latest' - 'preview' - 'nightly' dry-run: description: 'Whether to run in dry-run mode.' required: false type: 'boolean' default: true environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: change-tags: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: packages: 'write' issues: 'write' steps: - name: 'Checkout repository' uses: 'actions/checkout@v4' with: ref: '${{ github.ref }}' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' - name: 'Change tag' uses: './.github/actions/tag-npm-release' with: channel: '${{ github.event.inputs.channel }}' version: '${{ github.event.inputs.version }}' dry-run: '${{ github.event.inputs.dry-run }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' working-directory: '.' ================================================ FILE: .github/workflows/release-manual.yml ================================================ name: 'Release: Manual' on: workflow_dispatch: inputs: version: description: 'The version to release (e.g., v0.1.11). Must be a valid semver string with a "v" prefix.' required: true type: 'string' ref: description: 'The branch, tag, or SHA to release from.' required: true type: 'string' npm_channel: description: 'The npm channel to publish to' required: true type: 'choice' options: - 'dev' - 'preview' - 'nightly' - 'latest' default: 'latest' dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true type: 'boolean' default: true force_skip_tests: description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' required: false type: 'boolean' default: false skip_github_release: description: 'Select to skip creating a GitHub release (only used when environment is PROD)' required: false type: 'boolean' default: false environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: release: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' packages: 'write' issues: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 0 - name: 'Checkout Release Code' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' path: 'release' fetch-depth: 0 - name: 'Debug Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: './release/.nvmrc' cache: 'npm' - name: 'Install Dependencies' working-directory: './release' run: 'npm ci' - name: 'Prepare Release Info' id: 'release_info' working-directory: './release' env: INPUT_VERSION: '${{ github.event.inputs.version }}' run: | RELEASE_VERSION="${INPUT_VERSION}" echo "RELEASE_VERSION=${RELEASE_VERSION#v}" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_TAG=$(git describe --tags --abbrev=0)" >> "${GITHUB_OUTPUT}" - name: 'Run Tests' if: "${{github.event.inputs.force_skip_tests != 'true'}}" uses: './.github/actions/run-tests' with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' working-directory: './release' - name: 'Publish Release' uses: './.github/actions/publish-release' with: force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' release-version: '${{ steps.release_info.outputs.RELEASE_VERSION }}' release-tag: '${{ github.event.inputs.version }}' npm-tag: '${{ github.event.inputs.npm_channel }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' previous-tag: '${{ steps.release_info.outputs.PREVIOUS_TAG }}' skip-github-release: '${{ github.event.inputs.skip_github_release }}' working-directory: './release' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry_run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: '${{ github.event.inputs.version }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Manual Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The manual release workflow failed. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' ================================================ FILE: .github/workflows/release-nightly.yml ================================================ name: 'Release: Nightly' on: schedule: - cron: '0 0 * * *' workflow_dispatch: inputs: dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true type: 'boolean' default: true force_skip_tests: description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' required: false type: 'boolean' default: true ref: description: 'The branch, tag, or SHA to release from.' required: false type: 'string' default: 'main' environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: release: if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: contents: 'write' packages: 'write' issues: 'write' pull-requests: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 0 - name: 'Checkout Release Code' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' path: 'release' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version-file: './release/.nvmrc' cache: 'npm' - name: 'Install Dependencies' working-directory: './release' run: 'npm ci' - name: 'Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(github.event.inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Calculate Release Variables' id: 'vars' uses: './.github/actions/calculate-vars' with: dry_run: '${{ github.event.inputs.dry_run }}' - name: 'Print Calculated vars' shell: 'bash' env: JSON_VARS: '${{ toJSON(steps.vars.outputs) }}' run: 'echo "$JSON_VARS"' - name: 'Run Tests' if: "${{ github.event_name == 'schedule' || github.event.inputs.force_skip_tests == 'false' }}" uses: './.github/actions/run-tests' with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' working-directory: './release' - name: 'Get Nightly Version' id: 'nightly_version' working-directory: './release' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: | # Calculate the version using the centralized script VERSION_JSON=$(node scripts/get-release-version.js --type=nightly) # Extract values for logging and outputs RELEASE_TAG=$(echo "${VERSION_JSON}" | jq -r .releaseTag) RELEASE_VERSION=$(echo "${VERSION_JSON}" | jq -r .releaseVersion) NPM_TAG=$(echo "${VERSION_JSON}" | jq -r .npmTag) PREVIOUS_TAG=$(echo "${VERSION_JSON}" | jq -r .previousReleaseTag) # Print calculated values for logging echo "Calculated Release Tag: ${RELEASE_TAG}" echo "Calculated Release Version: ${RELEASE_VERSION}" echo "Calculated Previous Tag: ${PREVIOUS_TAG}" # Set outputs for subsequent steps echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}" echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}" - name: 'Publish Release' if: true uses: './.github/actions/publish-release' with: release-version: '${{ steps.nightly_version.outputs.RELEASE_VERSION }}' release-tag: '${{ steps.nightly_version.outputs.RELEASE_TAG }}' npm-tag: '${{ steps.nightly_version.outputs.NPM_TAG }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ steps.vars.outputs.is_dry_run }}' previous-tag: '${{ steps.nightly_version.outputs.PREVIOUS_TAG }}' working-directory: './release' skip-branch-cleanup: true force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}" gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' - name: 'Create and Merge Pull Request' if: "github.event.inputs.environment != 'dev'" uses: './.github/actions/create-pull-request' with: branch-name: 'release/${{ steps.nightly_version.outputs.RELEASE_TAG }}' pr-title: 'chore/release: bump version to ${{ steps.nightly_version.outputs.RELEASE_VERSION }}' pr-body: 'Automated version bump for nightly release.' github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ steps.vars.outputs.is_dry_run }}' working-directory: './release' - name: 'Create Issue on Failure' if: "${{ failure() && github.event.inputs.environment != 'dev' && (github.event_name == 'schedule' || github.event.inputs.dry_run != 'true') }}" env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: '${{ steps.nightly_version.outputs.RELEASE_TAG }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title "Nightly Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ --body "The nightly-release workflow failed. See the full run for details: ${DETAILS_URL}" \ --label 'release-failure,priority/p0' ================================================ FILE: .github/workflows/release-notes.yml ================================================ # This workflow is triggered on every new release. # It uses Gemini to generate release notes and creates a PR with the changes. name: 'Generate Release Notes' on: release: types: ['published'] workflow_dispatch: inputs: version: description: 'New version (e.g., v1.2.3)' required: true type: 'string' body: description: 'Release notes body' required: true type: 'string' time: description: 'Release time' required: true type: 'string' jobs: generate-release-notes: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' pull-requests: 'write' steps: - name: 'Checkout repository' uses: 'actions/checkout@v4' with: # The user-level skills need to be available to the workflow fetch-depth: 0 ref: 'main' - name: 'Set up Node.js' uses: 'actions/setup-node@v4' with: node-version: '20' - name: 'Get release information' id: 'release_info' run: | VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}" TIME="${{ github.event.inputs.time || github.event.release.created_at }}" echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" echo "TIME=${TIME}" >> "$GITHUB_OUTPUT" # Use a heredoc to preserve multiline release body echo 'RAW_CHANGELOG<> "$GITHUB_OUTPUT" printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT" echo 'EOF' >> "$GITHUB_OUTPUT" env: GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' BODY: '${{ github.event.inputs.body || github.event.release.body }}' - name: 'Validate version' id: 'validate_version' run: | if echo "${{ steps.release_info.outputs.VERSION }}" | grep -q "nightly"; then echo "Nightly release detected. Stopping workflow." echo "CONTINUE=false" >> "$GITHUB_OUTPUT" else echo "CONTINUE=true" >> "$GITHUB_OUTPUT" fi - name: 'Generate Changelog with Gemini' if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' prompt: | Activate the 'docs-changelog' skill. **Release Information:** - New Version: ${{ steps.release_info.outputs.VERSION }} - Release Date: ${{ steps.release_info.outputs.TIME }} - Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }} Execute the release notes generation process using the information provided. When you are done, please output your thought process and the steps you took for future debugging purposes. - name: 'Create Pull Request' if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'peter-evans/create-pull-request@v6' with: token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}' title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}' body: | This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release. Please review and merge. Related to #18505 branch: 'changelog-${{ steps.release_info.outputs.VERSION }}' base: 'main' team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' delete-branch: true ================================================ FILE: .github/workflows/release-patch-0-from-comment.yml ================================================ name: 'Release: Patch (0) from Comment' on: issue_comment: types: ['created'] jobs: slash-command: runs-on: 'ubuntu-latest' # Only run if the comment is from a human user (not automated) if: "github.event.comment.user.type == 'User' && github.event.comment.user.login != 'github-actions[bot]'" permissions: contents: 'write' pull-requests: 'write' actions: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 1 - name: 'Slash Command Dispatch' id: 'slash_command' uses: 'peter-evans/slash-command-dispatch@40877f718dce0101edfc7aea2b3800cc192f9ed5' with: token: '${{ secrets.GITHUB_TOKEN }}' commands: 'patch' permission: 'write' issue-type: 'pull-request' - name: 'Get PR Status' id: 'pr_status' if: "startsWith(github.event.comment.body, '/patch')" env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' run: | gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT" echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT" - name: 'Dispatch if Merged' if: "steps.pr_status.outputs.STATE == 'MERGED'" id: 'dispatch_patch' uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' env: COMMENT_BODY: '${{ github.event.comment.body }}' with: github-token: '${{ secrets.GITHUB_TOKEN }}' script: | // Parse the comment body directly to extract channel(s) const commentBody = process.env.COMMENT_BODY; console.log('Comment body:', commentBody); let channels = ['stable', 'preview']; // default to both // Parse different formats: // /patch (defaults to both) // /patch both // /patch stable // /patch preview if (commentBody.trim() === '/patch' || commentBody.trim() === '/patch both') { channels = ['stable', 'preview']; } else if (commentBody.trim() === '/patch stable') { channels = ['stable']; } else if (commentBody.trim() === '/patch preview') { channels = ['preview']; } else { // Fallback parsing for legacy formats if (commentBody.includes('channel=preview')) { channels = ['preview']; } else if (commentBody.includes('--channel preview')) { channels = ['preview']; } } console.log('Detected channels:', channels); const dispatchedRuns = []; // Dispatch workflow for each channel for (const channel of channels) { console.log(`Dispatching workflow for channel: ${channel}`); const response = await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'release-patch-1-create-pr.yml', ref: 'main', inputs: { commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}', channel: channel, original_pr: '${{ github.event.issue.number }}', environment: 'prod' } }); dispatchedRuns.push({ channel, response }); } // Wait a moment for the workflows to be created await new Promise(resolve => setTimeout(resolve, 3000)); const runs = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'release-patch-1-create-pr.yml', per_page: 20 // Increased to handle multiple runs }); // Find the recent runs that match our trigger const recentRuns = runs.data.workflow_runs.filter(run => run.event === 'workflow_dispatch' && new Date(run.created_at) > new Date(Date.now() - 15000) // Within last 15 seconds ).slice(0, channels.length); // Limit to the number of channels we dispatched // Set outputs core.setOutput('dispatched_channels', channels.join(',')); core.setOutput('dispatched_run_count', channels.length.toString()); if (recentRuns.length > 0) { core.setOutput('dispatched_run_urls', recentRuns.map(r => r.html_url).join(',')); core.setOutput('dispatched_run_ids', recentRuns.map(r => r.id).join(',')); const markdownLinks = recentRuns.map(r => `- [View dispatched workflow run](${r.html_url})`).join('\n'); core.setOutput('dispatched_run_links', markdownLinks); } - name: 'Comment on Failure' if: "startsWith(github.event.comment.body, '/patch') && steps.pr_status.outputs.STATE != 'MERGED'" uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' with: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | :x: The `/patch` command failed. This pull request must be merged before a patch can be created. - name: 'Final Status Comment - Success' if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && steps.dispatch_patch.outputs.dispatched_run_urls" uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' with: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | 🚀 **[Step 1/4] Patch workflow(s) waiting for approval!** **📋 Details:** - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} **⏳ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the specific workflow links below and approve the runs. **🔗 Track Progress:** ${{ steps.dispatch_patch.outputs.dispatched_run_links }} - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - name: 'Final Status Comment - Dispatch Success (No URL)' if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && !steps.dispatch_patch.outputs.dispatched_run_urls" uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' with: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | 🚀 **[Step 1/4] Patch workflow(s) waiting for approval!** **📋 Details:** - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} **⏳ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the workflow history link below and approve the runs. **🔗 Track Progress:** - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - name: 'Final Status Comment - Failure' if: "always() && startsWith(github.event.comment.body, '/patch') && (steps.dispatch_patch.outcome == 'failure' || steps.dispatch_patch.outcome == 'cancelled')" uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' with: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | ❌ **[Step 1/4] Patch workflow dispatch failed!** There was an error dispatching the patch creation workflow. **🔍 Troubleshooting:** - Check that the PR is properly merged - Verify workflow permissions - Review error logs in the workflow run **🔗 Debug Links:** - [This workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - [Patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) ================================================ FILE: .github/workflows/release-patch-1-create-pr.yml ================================================ name: 'Release: Patch (1) Create PR' run-name: >- Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }} on: workflow_dispatch: inputs: commit: description: 'The commit SHA to cherry-pick for the patch.' required: true type: 'string' channel: description: 'The release channel to patch.' required: true type: 'choice' options: - 'stable' - 'preview' dry_run: description: 'Whether to run in dry-run mode.' required: false type: 'boolean' default: false ref: description: 'The branch, tag, or SHA to test from.' required: false type: 'string' default: 'main' original_pr: description: 'The original PR number to comment back on.' required: false type: 'string' environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: create-patch: runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' pull-requests: 'write' actions: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: ref: '${{ github.event.inputs.ref }}' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'npm' - name: 'configure .npmrc' uses: './.github/actions/setup-npmrc' with: github-token: '${{ secrets.GITHUB_TOKEN }}' - name: 'Install Script Dependencies' run: 'npm ci' - name: 'Configure Git User' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' REPOSITORY: '${{ github.repository }}' run: |- git config user.name "gemini-cli-robot" git config user.email "gemini-cli-robot@google.com" # Configure git to use GITHUB_TOKEN for remote operations (has actions:write for workflow files) git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" - name: 'Create Patch' id: 'create_patch' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' PATCH_COMMIT: '${{ github.event.inputs.commit }}' PATCH_CHANNEL: '${{ github.event.inputs.channel }}' ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' continue-on-error: true run: | # Capture output and display it in logs using tee { node scripts/releasing/create-patch-pr.js \ --cli-package-name="${CLI_PACKAGE_NAME}" \ --commit="${PATCH_COMMIT}" \ --channel="${PATCH_CHANNEL}" \ --pullRequestNumber="${ORIGINAL_PR}" \ --dry-run="${DRY_RUN}" } 2>&1 | tee >( echo "LOG_CONTENT<> "$GITHUB_ENV" cat >> "$GITHUB_ENV" echo "EOF" >> "$GITHUB_ENV" ) echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" - name: 'Comment on Original PR' if: 'always() && inputs.original_pr' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}' COMMIT: '${{ github.event.inputs.commit }}' CHANNEL: '${{ github.event.inputs.channel }}' REPOSITORY: '${{ github.repository }}' GITHUB_RUN_ID: '${{ github.run_id }}' LOG_CONTENT: '${{ env.LOG_CONTENT }}' TARGET_REF: '${{ github.event.inputs.ref }}' ENVIRONMENT: '${{ github.event.inputs.environment }}' continue-on-error: true run: | git checkout "${TARGET_REF}" node scripts/releasing/patch-create-comment.js - name: 'Fail Workflow if Main Task Failed' if: 'always() && steps.create_patch.outputs.EXIT_CODE != 0' env: EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}' run: | echo "Patch creation failed with exit code: ${EXIT_CODE}" echo "Check the logs above and the comment posted to the original PR for details." exit 1 ================================================ FILE: .github/workflows/release-patch-2-trigger.yml ================================================ name: 'Release: Patch (2) Trigger' run-name: >- Release Patch (2) Trigger | ${{ github.event.pull_request.number && format('PR #{0}', github.event.pull_request.number) || 'Manual' }} | ${{ github.event.pull_request.head.ref || github.event.inputs.ref }} on: pull_request: types: - 'closed' branches: - 'release/**' workflow_dispatch: inputs: ref: description: 'The head ref of the merged hotfix PR to trigger the release for (e.g. hotfix/v1.2.3/cherry-pick-abc).' required: true type: 'string' workflow_ref: description: 'The ref to checkout the workflow code from.' required: false type: 'string' default: 'main' workflow_id: description: 'The workflow to trigger. Defaults to release-patch-3-release.yml' required: false type: 'string' default: 'release-patch-3-release.yml' dry_run: description: 'Whether this is a dry run.' required: false type: 'boolean' default: false force_skip_tests: description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' required: false type: 'boolean' default: false test_mode: description: 'Whether or not to run in test mode' required: false type: 'boolean' default: false environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: trigger-patch-release: if: "(github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'hotfix/')) || github.event_name == 'workflow_dispatch'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: actions: 'write' contents: 'write' pull-requests: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: "${{ github.event.inputs.workflow_ref || 'main' }}" fetch-depth: 1 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' run: 'npm ci' - name: 'Trigger Patch Release' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' HEAD_REF: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.event.inputs.ref }}" PR_BODY: "${{ github.event_name == 'pull_request' && github.event.pull_request.body || '' }}" WORKFLOW_ID: '${{ github.event.inputs.workflow_id }}' GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' GITHUB_EVENT_NAME: '${{ github.event_name }}' GITHUB_EVENT_PAYLOAD: '${{ toJSON(github.event) }}' FORCE_SKIP_TESTS: '${{ github.event.inputs.force_skip_tests }}' TEST_MODE: '${{ github.event.inputs.test_mode }}' ENVIRONMENT: "${{ github.event.inputs.environment || 'prod' }}" DRY_RUN: '${{ github.event.inputs.dry_run }}' run: | node scripts/releasing/patch-trigger.js --dry-run="${DRY_RUN}" ================================================ FILE: .github/workflows/release-patch-3-release.yml ================================================ name: 'Release: Patch (3) Release' run-name: >- Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }} on: workflow_dispatch: inputs: type: description: 'The type of release to perform.' required: true type: 'choice' options: - 'stable' - 'preview' dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true type: 'boolean' default: true force_skip_tests: description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' required: false type: 'boolean' default: false release_ref: description: 'The branch, tag, or SHA to release from.' required: true type: 'string' original_pr: description: 'The original PR number to comment back on.' required: false type: 'string' environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: release: runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' packages: 'write' pull-requests: 'write' issues: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 0 fetch-tags: true - name: 'Checkout Release Code' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.release_ref }}' path: 'release' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'npm' - name: 'configure .npmrc' uses: './.github/actions/setup-npmrc' with: github-token: '${{ secrets.GITHUB_TOKEN }}' - name: 'Install Script Dependencies' run: |- npm ci - name: 'Install Dependencies' working-directory: './release' run: |- npm ci - name: 'Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Get Patch Version' id: 'patch_version' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' PATCH_FROM: '${{ github.event.inputs.type }}' CLI_PACKAGE_NAME: '${{vars.CLI_PACKAGE_NAME}}' run: | # Use the existing get-release-version.js script to calculate patch version # Run from main checkout which has full git history and access to npm PATCH_JSON=$(node scripts/get-release-version.js --type=patch --cli-package-name="${CLI_PACKAGE_NAME}" --patch-from="${PATCH_FROM}") echo "Patch version calculation result: ${PATCH_JSON}" RELEASE_VERSION=$(echo "${PATCH_JSON}" | jq -r .releaseVersion) RELEASE_TAG=$(echo "${PATCH_JSON}" | jq -r .releaseTag) NPM_TAG=$(echo "${PATCH_JSON}" | jq -r .npmTag) PREVIOUS_TAG=$(echo "${PATCH_JSON}" | jq -r .previousReleaseTag) echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}" echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}" - name: 'Verify Version Consistency' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' CHANNEL: '${{ github.event.inputs.type }}' ORIGINAL_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' ORIGINAL_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' ORIGINAL_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' VARS_CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' run: | echo "🔍 Verifying no concurrent patch releases have occurred..." # Store original calculation for comparison echo "Original calculation:" echo " Release version: ${ORIGINAL_RELEASE_VERSION}" echo " Release tag: ${ORIGINAL_RELEASE_TAG}" echo " Previous tag: ${ORIGINAL_PREVIOUS_TAG}" # Re-run the same version calculation script echo "Re-calculating version to check for changes..." CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${VARS_CLI_PACKAGE_NAME}" --type=patch --patch-from="${CHANNEL}") CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion) CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag) CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag) echo "Current calculation:" echo " Release version: ${CURRENT_RELEASE_VERSION}" echo " Release tag: ${CURRENT_RELEASE_TAG}" echo " Previous tag: ${CURRENT_PREVIOUS_TAG}" # Compare calculations if [[ "${ORIGINAL_RELEASE_VERSION}" != "${CURRENT_RELEASE_VERSION}" ]] || \ [[ "${ORIGINAL_RELEASE_TAG}" != "${CURRENT_RELEASE_TAG}" ]] || \ [[ "${ORIGINAL_PREVIOUS_TAG}" != "${CURRENT_PREVIOUS_TAG}" ]]; then echo "❌ RACE CONDITION DETECTED: Version calculations have changed!" echo "This indicates another patch release completed while this one was in progress." echo "" echo "Originally planned: ${ORIGINAL_RELEASE_VERSION} (from ${ORIGINAL_PREVIOUS_TAG})" echo "Should now build: ${CURRENT_RELEASE_VERSION} (from ${CURRENT_PREVIOUS_TAG})" echo "" echo "# Setting outputs for failure comment" echo "CURRENT_RELEASE_VERSION=${CURRENT_RELEASE_VERSION}" >> "${GITHUB_ENV}" echo "CURRENT_RELEASE_TAG=${CURRENT_RELEASE_TAG}" >> "${GITHUB_ENV}" echo "CURRENT_PREVIOUS_TAG=${CURRENT_PREVIOUS_TAG}" >> "${GITHUB_ENV}" echo "The patch release must be restarted to use the correct version numbers." exit 1 fi echo "✅ Version calculations unchanged - proceeding with release" - name: 'Print Calculated Version' run: |- echo "Patch Release Summary:" echo " Release Version: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION}" echo " Release Tag: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG}" echo " NPM Tag: ${STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG}" echo " Previous Tag: ${STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG}" env: STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' - name: 'Run Tests' if: "${{github.event.inputs.force_skip_tests != 'true'}}" uses: './.github/actions/run-tests' with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' working-directory: './release' - name: 'Publish Release' uses: './.github/actions/publish-release' with: release-version: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' release-tag: '${{ steps.patch_version.outputs.RELEASE_TAG }}' npm-tag: '${{ steps.patch_version.outputs.NPM_TAG }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' previous-tag: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' working-directory: './release' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry_run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Patch Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The patch-release workflow failed. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' - name: 'Comment Success on Original PR' if: '${{ success() && github.event.inputs.original_pr }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' SUCCESS: 'true' RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' CHANNEL: '${{ github.event.inputs.type }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' GITHUB_RUN_ID: '${{ github.run_id }}' GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' run: | node scripts/releasing/patch-comment.js - name: 'Comment Failure on Original PR' if: '${{ failure() && github.event.inputs.original_pr }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' SUCCESS: 'false' RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' CHANNEL: '${{ github.event.inputs.type }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' GITHUB_RUN_ID: '${{ github.run_id }}' GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' # Pass current version info for race condition failures CURRENT_RELEASE_VERSION: '${{ env.CURRENT_RELEASE_VERSION }}' CURRENT_RELEASE_TAG: '${{ env.CURRENT_RELEASE_TAG }}' CURRENT_PREVIOUS_TAG: '${{ env.CURRENT_PREVIOUS_TAG }}' run: | # Check if this was a version consistency failure if [[ -n "${CURRENT_RELEASE_VERSION}" ]]; then echo "Detected version race condition failure - posting specific comment with current version info" export RACE_CONDITION_FAILURE=true fi node scripts/releasing/patch-comment.js ================================================ FILE: .github/workflows/release-promote.yml ================================================ name: 'Release: Promote' on: workflow_dispatch: inputs: dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' required: true type: 'boolean' default: true force_skip_tests: description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' required: false type: 'boolean' default: false ref: description: 'The branch, tag, or SHA to release from.' required: false type: 'string' default: 'main' stable_version_override: description: 'Manually override the stable version number.' required: false type: 'string' preview_version_override: description: 'Manually override the preview version number.' required: false type: 'string' environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: calculate-versions: name: 'Calculate Versions and Plan' runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" outputs: STABLE_VERSION: '${{ steps.versions.outputs.STABLE_VERSION }}' STABLE_SHA: '${{ steps.versions.outputs.STABLE_SHA }}' PREVIOUS_STABLE_TAG: '${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}' PREVIEW_VERSION: '${{ steps.versions.outputs.PREVIEW_VERSION }}' PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 0 fetch-tags: true - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' run: 'npm ci' - name: 'Print Inputs' shell: 'bash' env: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Calculate Versions and SHAs' id: 'versions' env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' STABLE_OVERRIDE: '${{ github.event.inputs.stable_version_override }}' PREVIEW_OVERRIDE: '${{ github.event.inputs.preview_version_override }}' REF_INPUT: '${{ github.event.inputs.ref }}' run: | set -e STABLE_COMMAND="node scripts/get-release-version.js --type=stable" if [[ -n "${STABLE_OVERRIDE}" ]]; then STABLE_COMMAND+=" --stable_version_override=${STABLE_OVERRIDE}" fi PREVIEW_COMMAND="node scripts/get-release-version.js --type=preview" if [[ -n "${PREVIEW_OVERRIDE}" ]]; then PREVIEW_COMMAND+=" --preview_version_override=${PREVIEW_OVERRIDE}" fi NIGHTLY_COMMAND="node scripts/get-release-version.js --type=promote-nightly" STABLE_JSON=$(${STABLE_COMMAND}) STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion) PREVIEW_COMMAND+=" --stable-base-version=${STABLE_VERSION}" NIGHTLY_COMMAND+=" --stable-base-version=${STABLE_VERSION}" PREVIEW_JSON=$(${PREVIEW_COMMAND}) NIGHTLY_JSON=$(${NIGHTLY_COMMAND}) echo "STABLE_JSON_COMMAND=${STABLE_COMMAND}" echo "PREVIEW_JSON_COMMAND=${PREVIEW_COMMAND}" echo "NIGHTLY_JSON_COMMAND=${NIGHTLY_COMMAND}" echo "STABLE_JSON: ${STABLE_JSON}" echo "PREVIEW_JSON: ${PREVIEW_JSON}" echo "NIGHTLY_JSON: ${NIGHTLY_JSON}" echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}" # shellcheck disable=SC1083 echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" # shellcheck disable=SC1083 REF="${REF_INPUT}" SHA=$(git ls-remote origin "$REF" | awk -v ref="$REF" '$2 == "refs/heads/"ref || $2 == "refs/tags/"ref || $2 == ref {print $1}' | head -n 1) if [ -z "$SHA" ]; then if [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]]; then SHA="$REF" else echo "::error::Could not resolve ref '$REF' to a commit SHA." exit 1 fi fi echo "PREVIEW_SHA=$SHA" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" echo "NEXT_NIGHTLY_VERSION=$(echo "${NIGHTLY_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_NIGHTLY_TAG=$(echo "${NIGHTLY_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" CURRENT_NIGHTLY_TAG=$(git describe --tags --abbrev=0 --match="*nightly*") echo "CURRENT_NIGHTLY_TAG=${CURRENT_NIGHTLY_TAG}" >> "${GITHUB_OUTPUT}" echo "NEXT_SHA=$SHA" >> "${GITHUB_OUTPUT}" - name: 'Display Pending Updates' env: STABLE_VERSION: '${{ steps.versions.outputs.STABLE_VERSION }}' STABLE_SHA: '${{ steps.versions.outputs.STABLE_SHA }}' PREVIOUS_STABLE_TAG: '${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}' PREVIEW_VERSION: '${{ steps.versions.outputs.PREVIEW_VERSION }}' PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}' INPUT_REF: '${{ github.event.inputs.ref }}' run: | echo "Release Plan:" echo "-----------" echo "Stable Release: ${STABLE_VERSION}" echo " - Commit: ${STABLE_SHA}" echo " - Previous Tag: ${PREVIOUS_STABLE_TAG}" echo "" echo "Preview Release: ${PREVIEW_VERSION}" echo " - Commit: ${PREVIEW_SHA} (${INPUT_REF})" echo " - Previous Tag: ${PREVIOUS_PREVIEW_TAG}" echo "" echo "Preparing Next Nightly Release: ${NEXT_NIGHTLY_VERSION}" echo " - Merging Version Update PR to Branch: ${INPUT_REF}" echo " - Previous Tag: ${PREVIOUS_NIGHTLY_TAG}" test: name: 'Test ${{ matrix.channel }}' needs: 'calculate-versions' runs-on: 'ubuntu-latest' strategy: fail-fast: false matrix: include: - channel: 'stable' sha: '${{ needs.calculate-versions.outputs.STABLE_SHA }}' - channel: 'preview' sha: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}' - channel: 'nightly' sha: '${{ github.event.inputs.ref }}' steps: - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' - name: 'Checkout correct SHA' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ matrix.sha }}' path: 'release' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' working-directory: './release' run: 'npm ci' - name: 'Run Tests' if: "${{github.event.inputs.force_skip_tests != 'true'}}" uses: './.github/actions/run-tests' with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' working-directory: './release' publish-preview: name: 'Publish preview' needs: ['calculate-versions', 'test'] runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' packages: 'write' issues: 'write' steps: - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' - name: 'Checkout correct SHA' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}' path: 'release' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' working-directory: './release' run: 'npm ci' - name: 'Publish Release' uses: './.github/actions/publish-release' with: release-version: '${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' release-tag: 'v${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' npm-tag: 'preview' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}' working-directory: './release' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry_run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The promote-release workflow failed during preview publish. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' publish-stable: name: 'Publish stable' needs: ['calculate-versions', 'test', 'publish-preview'] runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' packages: 'write' issues: 'write' steps: - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' - name: 'Checkout correct SHA' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ needs.calculate-versions.outputs.STABLE_SHA }}' path: 'release' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' working-directory: './release' run: 'npm ci' - name: 'Publish Release' uses: './.github/actions/publish-release' with: release-version: '${{ needs.calculate-versions.outputs.STABLE_VERSION }}' release-tag: 'v${{ needs.calculate-versions.outputs.STABLE_VERSION }}' npm-tag: 'latest' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_STABLE_TAG }}' working-directory: './release' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry_run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.STABLE_VERSION }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The promote-release workflow failed during stable publish. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' nightly-pr: name: 'Create Nightly PR' needs: ['publish-stable', 'calculate-versions'] runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' pull-requests: 'write' issues: 'write' steps: - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref }}' - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' cache: 'npm' - name: 'Install Dependencies' run: 'npm ci' - name: 'Configure Git User' run: |- git config user.name "gemini-cli-robot" git config user.email "gemini-cli-robot@google.com" - name: 'Create and switch to a new branch' id: 'release_branch' run: | BRANCH_NAME="chore/nightly-version-bump-${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" env: NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Update package versions' run: 'npm run release:version "${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"' env: NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Commit and Push package versions' env: BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' run: |- git add package.json packages/*/package.json if [ -f package-lock.json ]; then git add package-lock.json fi git commit -m "chore(release): bump version to ${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." git push --set-upstream origin "${BRANCH_NAME}" else echo "Dry run enabled. Skipping push." fi - name: 'Create and Merge Pull Request' uses: './.github/actions/create-pull-request' with: branch-name: '${{ steps.release_branch.outputs.BRANCH_NAME }}' pr-title: 'chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' pr-body: 'Automated version bump to prepare for the next nightly release.' github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry_run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The promote-release workflow failed during nightly PR creation. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' ================================================ FILE: .github/workflows/release-rollback.yml ================================================ name: 'Release: Rollback change' on: workflow_dispatch: inputs: rollback_origin: description: 'The package version to rollback FROM and delete (e.g., 0.5.0-preview-2)' required: true type: 'string' rollback_destination: description: 'The package version to rollback TO (e.g., 0.5.0-preview-2). This version must already exist on the npm registry.' required: false type: 'string' channel: description: 'The npm dist-tag to apply to rollback_destination (e.g., latest, preview, nightly). REQUIRED IF rollback_destination is set.' required: false type: 'choice' options: - 'latest' - 'preview' - 'nightly' - 'dev' default: 'dev' ref: description: 'The branch, tag, or SHA to run from.' required: false type: 'string' default: 'main' dry-run: description: 'Whether to run in dry-run mode.' required: false type: 'boolean' default: true environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: change-tags: if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: packages: 'write' issues: 'write' steps: - name: 'Checkout repository' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 with: ref: '${{ github.event.inputs.ref }}' fetch-depth: 0 - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' with: node-version-file: '.nvmrc' - name: 'configure .npmrc' uses: './.github/actions/setup-npmrc' with: github-token: '${{ secrets.GITHUB_TOKEN }}' - name: 'Get Origin Version Tag' id: 'origin_tag' shell: 'bash' env: ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' run: | TAG_VALUE="v${ROLLBACK_ORIGIN}" echo "ORIGIN_TAG=$TAG_VALUE" >> "$GITHUB_OUTPUT" - name: 'Get Origin Commit Hash' id: 'origin_hash' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' shell: 'bash' run: | echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT" - name: 'Change tag' if: "${{ github.event.inputs.rollback_destination != '' }}" uses: './.github/actions/tag-npm-release' with: channel: '${{ github.event.inputs.channel }}' version: '${{ github.event.inputs.rollback_destination }}' dry-run: '${{ github.event.inputs.dry-run }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' github-token: '${{ secrets.GITHUB_TOKEN }}' cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' - name: 'Get cli Token' uses: './.github/actions/npm-auth-token' id: 'cli-token' with: package-name: '${{ vars.CLI_PACKAGE_NAME }}' github-token: '${{ secrets.GITHUB_TOKEN }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' - name: 'Deprecate Cli Npm Package' if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' shell: 'bash' run: | npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." - name: 'Get core Token' uses: './.github/actions/npm-auth-token' id: 'core-token' with: package-name: '${{ vars.CLI_PACKAGE_NAME }}' github-token: '${{ secrets.GITHUB_TOKEN }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' - name: 'Deprecate Core Npm Package' if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' PACKAGE_NAME: '${{ vars.CORE_PACKAGE_NAME }}' ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' shell: 'bash' run: | npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." - name: 'Get a2a Token' uses: './.github/actions/npm-auth-token' id: 'a2a-token' with: package-name: '${{ vars.A2A_PACKAGE_NAME }}' github-token: '${{ secrets.GITHUB_TOKEN }}' wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' - name: 'Deprecate A2A Server Npm Package' if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' PACKAGE_NAME: '${{ vars.A2A_PACKAGE_NAME }}' ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' shell: 'bash' run: | npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." - name: 'Delete Github Release' if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod'}}" env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' shell: 'bash' run: | gh release delete "${ORIGIN_TAG}" --yes - name: 'Verify Origin Release Deletion' if: "${{ github.event.inputs.dry-run == 'false' }}" env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' TARGET_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' shell: 'bash' run: | RELEASE_TAG=$(gh release view "$TARGET_TAG" --json tagName --jq .tagName) if [ "$RELEASE_TAG" = "$TARGET_TAG" ]; then echo "❌ Failed to delete release with tag ${TARGET_TAG}" echo '❌ This means the release was not deleted, and the workflow should fail.' exit 1 fi - name: 'Add Rollback Tag' id: 'rollback_tag' if: "${{ github.event.inputs.dry-run == 'false' }}" env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' ROLLBACK_TAG_NAME: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}-rollback' ORIGIN_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' shell: 'bash' run: | echo "ROLLBACK_TAG=$ROLLBACK_TAG_NAME" >> "$GITHUB_OUTPUT" git tag "$ROLLBACK_TAG_NAME" "${ORIGIN_HASH}" git push origin --tags - name: 'Verify Rollback Tag Added' if: "${{ github.event.inputs.dry-run == 'false' }}" env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' TARGET_TAG: '${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}' TARGET_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' shell: 'bash' run: | ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" echo '❌ This means the tag was not added, and the workflow should fail.' exit 1 fi - name: 'Log Dry run' if: "${{ github.event.inputs.dry-run == 'true' }}" env: ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' ROLLBACK_DESTINATION: '${{ github.event.inputs.rollback_destination }}' CHANNEL: '${{ github.event.inputs.channel }}' REF_INPUT: '${{ github.event.inputs.ref }}' ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' ORIGIN_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' ROLLBACK_TAG: '${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}' CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' CORE_PACKAGE_NAME: '${{ vars.CORE_PACKAGE_NAME }}' A2A_PACKAGE_NAME: '${{ vars.A2A_PACKAGE_NAME }}' shell: 'bash' run: | echo " Inputs: - rollback_origin: '${ROLLBACK_ORIGIN}' - rollback_destination: '${ROLLBACK_DESTINATION}' - channel: '${CHANNEL}' - ref: '${REF_INPUT}' Outputs: - ORIGIN_TAG: '${ORIGIN_TAG}' - ORIGIN_HASH: '${ORIGIN_HASH}' - ROLLBACK_TAG: '${ROLLBACK_TAG}' Would have npm deprecate ${CLI_PACKAGE_NAME}@${ROLLBACK_ORIGIN}, ${CORE_PACKAGE_NAME}@${ROLLBACK_ORIGIN}, and ${A2A_PACKAGE_NAME}@${ROLLBACK_ORIGIN} Would have deleted the github release with tag ${ORIGIN_TAG} Would have added tag ${ORIGIN_TAG}-rollback to ${ORIGIN_HASH} " ================================================ FILE: .github/workflows/release-sandbox.yml ================================================ name: 'Release Sandbox' on: workflow_dispatch: inputs: ref: description: 'The branch, tag, or SHA to release from.' required: false type: 'string' default: 'main' dry-run: description: 'Whether this is a dry run.' required: false type: 'boolean' default: true jobs: build: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' packages: 'write' issues: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Push' uses: './.github/actions/push-sandbox' with: dockerhub-username: '${{ secrets.DOCKER_SERVICE_ACCOUNT_NAME }}' dockerhub-token: '${{ secrets.DOCKER_SERVICE_ACCOUNT_KEY }}' github-actor: '${{ github.actor }}' github-secret: '${{ secrets.GITHUB_TOKEN }}' github-sha: '${{ github.sha }}' github-ref-name: '${{github.event.inputs.ref}}' dry-run: '${{ github.event.inputs.dry-run }}' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry-run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: | gh issue create \ --title 'Sandbox Release Failed on $(date +'%Y-%m-%d')' \ --body 'The sandbox-release workflow failed. See the full run for details: ${DETAILS_URL}' \ --label 'release-failure,priority/p0' ================================================ FILE: .github/workflows/smoke-test.yml ================================================ name: 'On Merge Smoke Test' on: push: branches: - 'main' - 'release/**' workflow_dispatch: inputs: ref: description: 'The branch, tag, or SHA to test on.' required: false type: 'string' default: 'main' dry-run: description: 'Run a dry-run of the smoke test; No bug will be created' required: true type: 'boolean' default: true jobs: smoke-test: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' packages: 'write' issues: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - name: 'Install Dependencies' run: 'npm ci' - name: 'Build bundle' run: 'npm run bundle' - name: 'Smoke test bundle' run: 'node ./bundle/gemini.js --version' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry-run == false }}' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' REF: '${{ github.event.inputs.ref }}' run: | gh issue create \ --title 'Smoke test failed on ${REF} @ $(date +'%Y-%m-%d')' \ --body 'Smoke test build failed. See the full run for details: ${DETAILS_URL}' \ --label 'priority/p0' ================================================ FILE: .github/workflows/stale.yml ================================================ name: 'Mark stale issues and pull requests' # Run as a daily cron at 1:30 AM on: schedule: - cron: '30 1 * * *' workflow_dispatch: jobs: stale: strategy: fail-fast: false matrix: runner: - 'ubuntu-latest' # GitHub-hosted runs-on: '${{ matrix.runner }}' if: |- ${{ github.repository == 'google-gemini/gemini-cli' }} permissions: issues: 'write' pull-requests: 'write' concurrency: group: '${{ github.workflow }}-stale' cancel-in-progress: true steps: - uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9 with: repo-token: '${{ secrets.GITHUB_TOKEN }}' stale-issue-message: >- This issue has been automatically marked as stale due to 60 days of inactivity. It will be closed in 14 days if no further activity occurs. stale-pr-message: >- This pull request has been automatically marked as stale due to 60 days of inactivity. It will be closed in 14 days if no further activity occurs. close-issue-message: >- This issue has been closed due to 14 additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen the issue. Thank you! close-pr-message: >- This pull request has been closed due to 14 additional days of inactivity after being marked as stale. If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing! days-before-stale: 60 days-before-close: 14 exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap' exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap' ================================================ FILE: .github/workflows/test-build-binary.yml ================================================ name: 'Test Build Binary' on: workflow_dispatch: permissions: contents: 'read' defaults: run: shell: 'bash' jobs: build-node-binary: name: 'Build Binary (${{ matrix.os }})' runs-on: '${{ matrix.os }}' strategy: fail-fast: false matrix: include: - os: 'ubuntu-latest' platform_name: 'linux-x64' arch: 'x64' - os: 'windows-latest' platform_name: 'win32-x64' arch: 'x64' - os: 'macos-latest' # Apple Silicon (ARM64) platform_name: 'darwin-arm64' arch: 'arm64' - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta platform_name: 'darwin-x64' arch: 'x64' steps: - name: 'Checkout' uses: 'actions/checkout@v4' - name: 'Optimize Windows Performance' if: "matrix.os == 'windows-latest'" run: | Set-MpPreference -DisableRealtimeMonitoring $true Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue Set-Service -Name "wsearch" -StartupType Disabled Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue Set-Service -Name "SysMain" -StartupType Disabled shell: 'powershell' - name: 'Set up Node.js' uses: 'actions/setup-node@v4' with: node-version-file: '.nvmrc' architecture: '${{ matrix.arch }}' cache: 'npm' - name: 'Install dependencies' run: 'npm ci' - name: 'Check Secrets' id: 'check_secrets' run: | echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT" echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT" - name: 'Setup Windows SDK (Windows)' if: "matrix.os == 'windows-latest'" uses: 'microsoft/setup-msbuild@v2' - name: 'Add Signtool to Path (Windows)' if: "matrix.os == 'windows-latest'" run: | $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName echo "Found signtool at: $signtoolPath" echo "$signtoolPath" >> $env:GITHUB_PATH shell: 'pwsh' - name: 'Setup macOS Keychain' if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'" env: BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}' P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}' KEYCHAIN_PASSWORD: 'temp-password' run: | # Create the P12 file echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 # Create a temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain # Import the certificate security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign # Allow codesign to access it security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain # Set Identity for build script echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV" - name: 'Setup Windows Certificate' if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'" env: PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}' PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}' run: | $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64") $certPath = Join-Path (Get-Location) "cert.pfx" [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte) echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV shell: 'pwsh' - name: 'Build Binary' run: 'npm run build:binary' - name: 'Build Core Package' run: 'npm run build -w @google/gemini-cli-core' - name: 'Verify Output Exists' run: | if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then echo "Binary found at dist/${{ matrix.platform_name }}/gemini" elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe" else echo "Error: Binary not found in dist/${{ matrix.platform_name }}/" ls -R dist/ exit 1 fi - name: 'Smoke Test Binary' run: | echo "Running binary smoke test..." if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then "./dist/${{ matrix.platform_name }}/gemini.exe" --version else "./dist/${{ matrix.platform_name }}/gemini" --version fi - name: 'Run Integration Tests' if: "github.event_name != 'pull_request'" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: | echo "Running integration tests with binary..." if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")" else BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini" fi echo "Using binary at $BINARY_PATH" export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH" npm run test:integration:sandbox:none -- --testTimeout=600000 - name: 'Upload Artifact' uses: 'actions/upload-artifact@v4' with: name: 'gemini-cli-${{ matrix.platform_name }}' path: 'dist/${{ matrix.platform_name }}/' retention-days: 5 ================================================ FILE: .github/workflows/trigger_e2e.yml ================================================ name: 'Trigger E2E' on: workflow_dispatch: inputs: repo_name: description: 'Repository name (e.g., owner/repo)' required: false type: 'string' head_sha: description: 'SHA of the commit to test' required: false type: 'string' pull_request: jobs: save_repo_name: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Save Repo name' env: REPO_NAME: '${{ github.event.inputs.repo_name || github.event.pull_request.head.repo.full_name }}' HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}' run: | mkdir -p ./pr echo "${REPO_NAME}" > ./pr/repo_name echo "${HEAD_SHA}" > ./pr/head_sha - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' path: 'pr/' trigger_e2e: name: 'Trigger e2e' if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - id: 'trigger-e2e' run: | echo "Trigger e2e workflow" ================================================ FILE: .github/workflows/unassign-inactive-assignees.yml ================================================ name: 'Unassign Inactive Issue Assignees' # This workflow runs daily and scans every open "help wanted" issue that has # one or more assignees. For each assignee it checks whether they have a # non-draft pull request (open and ready for review, or already merged) that # is linked to the issue. Draft PRs are intentionally excluded so that # contributors cannot reset the check by opening a no-op PR. If no # qualifying PR is found within 7 days of assignment the assignee is # automatically removed and a friendly comment is posted so that other # contributors can pick up the work. # Maintainers, org members, and collaborators (anyone with write access or # above) are always exempted and will never be auto-unassigned. on: schedule: - cron: '0 9 * * *' # Every day at 09:00 UTC workflow_dispatch: inputs: dry_run: description: 'Run in dry-run mode (no changes will be applied)' required: false default: false type: 'boolean' concurrency: group: '${{ github.workflow }}' cancel-in-progress: true defaults: run: shell: 'bash' jobs: unassign-inactive-assignees: if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' steps: - name: 'Generate GitHub App Token' id: 'generate_token' uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Unassign inactive assignees' uses: 'actions/github-script@v7' env: DRY_RUN: '${{ inputs.dry_run }}' with: github-token: '${{ steps.generate_token.outputs.token }}' script: | const dryRun = process.env.DRY_RUN === 'true'; if (dryRun) { core.info('DRY RUN MODE ENABLED: No changes will be applied.'); } const owner = context.repo.owner; const repo = context.repo.repo; const GRACE_PERIOD_DAYS = 7; const now = new Date(); let maintainerLogins = new Set(); const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; for (const team_slug of teams) { try { const members = await github.paginate(github.rest.teams.listMembersInOrg, { org: owner, team_slug, }); for (const m of members) maintainerLogins.add(m.login.toLowerCase()); core.info(`Fetched ${members.length} members from team ${team_slug}.`); } catch (e) { core.warning(`Could not fetch team ${team_slug}: ${e.message}`); } } const isGooglerCache = new Map(); const isGoogler = async (login) => { if (isGooglerCache.has(login)) return isGooglerCache.get(login); try { for (const org of ['googlers', 'google']) { try { await github.rest.orgs.checkMembershipForUser({ org, username: login }); isGooglerCache.set(login, true); return true; } catch (e) { if (e.status !== 404) throw e; } } } catch (e) { core.warning(`Could not check org membership for ${login}: ${e.message}`); } isGooglerCache.set(login, false); return false; }; const permissionCache = new Map(); const isPrivilegedUser = async (login) => { if (maintainerLogins.has(login.toLowerCase())) return true; if (permissionCache.has(login)) return permissionCache.get(login); try { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login, }); const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission); permissionCache.set(login, privileged); if (privileged) { core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`); return true; } } catch (e) { if (e.status !== 404) { core.warning(`Could not check permission for ${login}: ${e.message}`); } } const googler = await isGoogler(login); permissionCache.set(login, googler); return googler; }; core.info('Fetching open "help wanted" issues with assignees...'); const issues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: 'help wanted', per_page: 100, }); const assignedIssues = issues.filter( (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0 ); core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`); let totalUnassigned = 0; let timelineEvents = []; try { timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100, mediaType: { previews: ['mockingbird'] }, }); } catch (err) { core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`); continue; } const assignedAtMap = new Map(); for (const event of timelineEvents) { if (event.event === 'assigned' && event.assignee) { const login = event.assignee.login.toLowerCase(); const at = new Date(event.created_at); assignedAtMap.set(login, at); } else if (event.event === 'unassigned' && event.assignee) { assignedAtMap.delete(event.assignee.login.toLowerCase()); } } const linkedPRAuthorSet = new Set(); const seenPRKeys = new Set(); for (const event of timelineEvents) { if ( event.event !== 'cross-referenced' || !event.source || event.source.type !== 'pull_request' || !event.source.issue || !event.source.issue.user || !event.source.issue.number || !event.source.issue.repository ) continue; const prOwner = event.source.issue.repository.owner.login; const prRepo = event.source.issue.repository.name; const prNumber = event.source.issue.number; const prAuthor = event.source.issue.user.login.toLowerCase(); const prKey = `${prOwner}/${prRepo}#${prNumber}`; if (seenPRKeys.has(prKey)) continue; seenPRKeys.add(prKey); try { const { data: pr } = await github.rest.pulls.get({ owner: prOwner, repo: prRepo, pull_number: prNumber, }); const isReady = (pr.state === 'open' && !pr.draft) || (pr.state === 'closed' && pr.merged_at !== null); core.info( ` PR ${prKey} by @${prAuthor}: ` + `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` + (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)') ); if (isReady) linkedPRAuthorSet.add(prAuthor); } catch (err) { core.warning(`Could not fetch PR ${prKey}: ${err.message}`); } } const assigneesToRemove = []; for (const assignee of issue.assignees) { const login = assignee.login.toLowerCase(); if (await isPrivilegedUser(assignee.login)) { core.info(` @${assignee.login}: privileged user — skipping.`); continue; } const assignedAt = assignedAtMap.get(login); if (!assignedAt) { core.warning( `No 'assigned' event found for @${login} on issue #${issue.number}; ` + `falling back to issue creation date (${issue.created_at}).` ); assignedAtMap.set(login, new Date(issue.created_at)); } const resolvedAssignedAt = assignedAtMap.get(login); const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24); core.info( ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` + `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}` ); if (daysSinceAssignment < GRACE_PERIOD_DAYS) { core.info(` → within grace period, skipping.`); continue; } if (linkedPRAuthorSet.has(login)) { core.info(` → ready-for-review PR found, keeping assignment.`); continue; } core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`); assigneesToRemove.push(assignee.login); } if (assigneesToRemove.length === 0) { continue; } if (!dryRun) { try { await github.rest.issues.removeAssignees({ owner, repo, issue_number: issue.number, assignees: assigneesToRemove, }); } catch (err) { core.warning( `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}` ); continue; } const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', '); const commentBody = `👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` + `you were assigned to this issue and we could not find a pull request ` + `ready for review.\n\n` + `To keep the backlog moving and ensure issues stay accessible to all ` + `contributors, we require a PR that is open and ready for review (not a ` + `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` + `We are automatically unassigning you so that other contributors can pick ` + `this up. If you are still actively working on this, please:\n` + `1. Re-assign yourself by commenting \`/assign\`.\n` + `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` + `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` + `Thank you for your contribution — we hope to see a PR from you soon! 🙏`; try { await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: commentBody, }); } catch (err) { core.warning( `Failed to post comment on issue #${issue.number}: ${err.message}` ); } } totalUnassigned += assigneesToRemove.length; core.info( ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}` ); } core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`); ================================================ FILE: .github/workflows/verify-release.yml ================================================ name: 'Verify NPM release tag' on: workflow_dispatch: inputs: version: description: 'The expected Gemini binary version that should be released (e.g., 0.5.0-preview-2).' required: true type: 'string' npm-tag: description: 'NPM tag to verify' required: true type: 'choice' options: - 'dev' - 'latest' - 'preview' - 'nightly' default: 'latest' environment: description: 'Environment' required: false type: 'choice' options: - 'prod' - 'dev' default: 'prod' jobs: verify-release: if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" strategy: fail-fast: false matrix: os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] runs-on: '${{ matrix.os }}' permissions: contents: 'read' packages: 'write' issues: 'write' steps: - name: '📝 Print vars' shell: 'bash' run: 'echo "${{ toJSON(vars) }}"' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' - name: 'Verify release' uses: './.github/actions/verify-release' with: npm-package: '${{vars.CLI_PACKAGE_NAME}}@${{github.event.inputs.npm-tag}}' expected-version: '${{github.event.inputs.version}}' working-directory: '.' gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' github-token: '${{ secrets.GITHUB_TOKEN }}' npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' ================================================ FILE: .gitignore ================================================ # API keys and secrets .env .env~ # gemini-cli settings # We want to keep the .gemini in the root of the repo and ignore any .gemini # in subdirectories. In our root .gemini we want to allow for version control # for subcommands. **/.gemini/ !/.gemini/ .gemini/* !.gemini/config.yaml !.gemini/commands/ !.gemini/skills/ !.gemini/settings.json # Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images # Dependency directory node_modules bower_components # Editors .idea *.iml # OS metadata .DS_Store Thumbs.db # TypeScript build info files *.tsbuildinfo # Ignore built ts files dist # Docker folder to help skip auth refreshes .docker bundle # Test report files junit.xml packages/*/coverage/ # Generated files packages/cli/src/generated/ packages/core/src/generated/ packages/devtools/src/_client-assets.ts .integration-tests/ packages/vscode-ide-companion/*.vsix packages/cli/download-ripgrep*/ # GHA credentials gha-creds-*.json # Log files patch_output.log gemini-debug.log .genkit .gemini-clipboard/ .eslintcache evals/logs/ temp_agents/ ================================================ FILE: .husky/pre-commit ================================================ npm run pre-commit || { echo '' echo '====================================================' echo 'pre-commit checks failed. in case of emergency, run:' echo '' echo 'git commit --no-verify' echo '====================================================' exit 1 } ================================================ FILE: .lycheeignore ================================================ http://localhost:16686/ https://github.com/google-gemini/gemini-cli/issues/new/choose https://github.com/google-gemini/maintainers-gemini-cli/blob/main/npm.md https://github.com/settings/personal-access-tokens/new https://github.com/settings/tokens/new https://www.npmjs.com/package/@google/gemini-cli ================================================ FILE: .npmrc ================================================ @google:registry=https://wombat-dressing-room.appspot.com ================================================ FILE: .nvmrc ================================================ 20 ================================================ FILE: .prettierignore ================================================ **/bundle **/coverage **/dist **/.git **/node_modules .docker .DS_Store .env .gemini/ .idea .integration-tests/ *.iml *.tsbuildinfo *.vsix bower_components eslint.config.js **/generated gha-creds-*.json junit.xml .gemini-linters/ Thumbs.db .pytest_cache **/SKILL.md packages/sdk/test-data/*.json ================================================ FILE: .prettierrc.json ================================================ { "semi": true, "trailingComma": "all", "singleQuote": true, "printWidth": 80, "tabWidth": 2, "overrides": [ { "files": ["**/*.md"], "options": { "tabWidth": 2, "printWidth": 80, "proseWrap": "always" } } ] } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "vitest.explorer", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint" ] } ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Build & Launch CLI", "runtimeExecutable": "npm", "runtimeArgs": ["run", "build-and-start"], "skipFiles": ["/**"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "env": { "GEMINI_SANDBOX": "false" } }, { "name": "Launch Companion VS Code Extension", "type": "extensionHost", "request": "launch", "args": [ "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-companion" ], "outFiles": [ "${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js" ], "preLaunchTask": "npm: build: vscode-ide-companion" }, { "name": "Attach", "port": 9229, "request": "attach", "skipFiles": ["/**"], "type": "node", // fix source mapping when debugging in sandbox using global installation // note this does not interfere when remoteRoot is also ${workspaceFolder}/packages "remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli", "localRoot": "${workspaceFolder}/packages" }, { "type": "node", "request": "launch", "name": "Launch Program", "skipFiles": ["/**"], "program": "${file}", "outFiles": ["${workspaceFolder}/**/*.js"] }, { "type": "node", "request": "launch", "name": "Debug Test File", "runtimeExecutable": "npm", "runtimeArgs": [ "run", "test", "-w", "packages", "--", "--inspect-brk=9229", "--no-file-parallelism", "${input:testFile}" ], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"] }, { "name": "Debug Integration Test File", "type": "node", "request": "launch", "runtimeExecutable": "npx", "runtimeArgs": [ "vitest", "run", "--root", "./integration-tests", "--inspect-brk=9229", "${file}" ], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"], "env": { "GEMINI_SANDBOX": "false" } } ], "inputs": [ { "id": "testFile", "type": "promptString", "description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)", "default": "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx" } ] } ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsserver.experimental.enableProjectDiagnostics": true, "editor.tabSize": 2, "editor.rulers": [80], "editor.detectIndentation": false, "editor.insertSpaces": true, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "vitest.disableWorkspaceWarning": true } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "type": "npm", "script": "build", "group": { "kind": "build", "isDefault": true }, "problemMatcher": [], "label": "npm: build", "detail": "scripts/build.sh" }, { "type": "npm", "script": "build", "path": "packages/vscode-ide-companion", "group": "build", "problemMatcher": [], "label": "npm: build: vscode-ide-companion", "detail": "npm run build -w packages/vscode-ide-companion" } ] } ================================================ FILE: .yamllint.yml ================================================ rules: anchors: forbid-duplicated-anchors: true forbid-undeclared-aliases: true forbid-unused-anchors: true braces: forbid: 'non-empty' min-spaces-inside-empty: 0 max-spaces-inside-empty: 0 brackets: min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: 0 max-spaces-inside-empty: 0 colons: max-spaces-before: 0 max-spaces-after: 1 commas: max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: require-starting-space: true ignore-shebangs: true min-spaces-from-content: 1 comments-indentation: 'disable' document-end: present: false document-start: present: false empty-lines: max: 2 max-start: 0 max-end: 1 empty-values: forbid-in-block-mappings: false forbid-in-flow-mappings: true float-values: forbid-inf: false forbid-nan: false forbid-scientific-notation: false require-numeral-before-decimal: false hyphens: max-spaces-after: 1 indentation: spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: {} new-line-at-end-of-file: {} new-lines: type: 'unix' octal-values: forbid-implicit-octal: true forbid-explicit-octal: false quoted-strings: quote-type: 'single' required: true allow-quoted-quotes: true trailing-spaces: {} truthy: allowed-values: ['true', 'false', 'on'] # GitHub Actions uses "on" check-keys: true ignore: - 'thirdparty/' - 'third_party/' - 'vendor/' - 'node_modules/' ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute We would love to accept your patches and contributions to this project. This document includes: - **[Before you begin](#before-you-begin):** Essential steps to take before becoming a Gemini CLI contributor. - **[Code contribution process](#code-contribution-process):** How to contribute code to Gemini CLI. - **[Development setup and workflow](#development-setup-and-workflow):** How to set up your development environment and workflow. - **[Documentation contribution process](#documentation-contribution-process):** How to contribute documentation to Gemini CLI. We're looking forward to seeing your contributions! ## Before you begin ### Sign our Contributor License Agreement Contributions to this project must be accompanied by a [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project. If you or your current employer have already signed the Google CLA (even if it was for a different project), you probably don't need to do it again. Visit to see your current agreements or to sign a new one. ### Review our Community Guidelines This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). ## Code contribution process ### Get started The process for contributing code is as follows: 1. **Find an issue** that you want to work on. If an issue is tagged as `🔒Maintainers only`, this means it is reserved for project maintainers. We will not accept pull requests related to these issues. In the near future, we will explicitly mark issues looking for contributions using the `help-wanted` label. If you believe an issue is a good candidate for community contribution, please leave a comment on the issue. A maintainer will review it and apply the `help-wanted` label if appropriate. Only maintainers should attempt to add the `help-wanted` label to an issue. 2. **Fork the repository** and create a new branch. 3. **Make your changes** in the `packages/` directory. 4. **Ensure all checks pass** by running `npm run preflight`. 5. **Open a pull request** with your changes. ### Code reviews All submissions, including submissions by project members, require review. We use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests) for this purpose. To assist with the review process, we provide an automated review tool that helps detect common anti-patterns, testing issues, and other best practices that are easy to miss. #### Using the automated review tool You can run the review tool in two ways: 1. **Using the helper script (Recommended):** We provide a script that automatically handles checking out the PR into a separate worktree, installing dependencies, building the project, and launching the review tool. ```bash ./scripts/review.sh [model] ``` **Warning:** If you run `scripts/review.sh`, you must have first verified that the code for the PR being reviewed is safe to run and does not contain data exfiltration attacks. **Authors are strongly encouraged to run this script on their own PRs** immediately after creation. This allows you to catch and fix simple issues locally before a maintainer performs a full review. **Note on Models:** By default, the script uses the latest Pro model (`gemini-3.1-pro-preview`). If you do not have enough Pro quota, you can run it with the latest Flash model instead: `./scripts/review.sh gemini-3-flash-preview`. 2. **Manually from within Gemini CLI:** If you already have the PR checked out and built, you can run the tool directly from the CLI prompt: ```text /review-frontend ``` Replace `` with your pull request number. Reviewers should use this tool to augment, not replace, their manual review process. ### Self-assigning and unassigning issues To assign an issue to yourself, simply add a comment with the text `/assign`. To unassign yourself from an issue, add a comment with the text `/unassign`. The comment must contain only that text and nothing else. These commands will assign or unassign the issue as requested, provided the conditions are met (e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given time. ### Pull request guidelines To help us review and merge your PRs quickly, please follow these guidelines. PRs that do not meet these standards may be closed. #### 1. Link to an existing issue All PRs should be linked to an existing issue in our tracker. This ensures that every change has been discussed and is aligned with the project's goals before any code is written. - **For bug fixes:** The PR should be linked to the bug report issue. - **For features:** The PR should be linked to the feature request or proposal issue that has been approved by a maintainer. If an issue for your change doesn't exist, we will automatically close your PR along with a comment reminding you to associate the PR with an issue. The ideal workflow starts with an issue that has been reviewed and approved by a maintainer. Please **open the issue first** and wait for feedback before you start coding. #### 2. Keep it small and focused We favor small, atomic PRs that address a single issue or add a single, self-contained feature. - **Do:** Create a PR that fixes one specific bug or adds one specific feature. - **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. #### 3. Use draft PRs for work in progress If you'd like to get early feedback on your work, please use GitHub's **Draft Pull Request** feature. This signals to the maintainers that the PR is not yet ready for a formal review but is open for discussion and initial feedback. #### 4. Ensure all checks pass Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks. #### 5. Update documentation If your PR introduces a user-facing change (e.g., a new command, a modified flag, or a change in behavior), you must also update the relevant documentation in the `/docs` directory. See more about writing documentation: [Documentation contribution process](#documentation-contribution-process). #### 6. Write clear commit messages and a good PR description Your PR should have a clear, descriptive title and a detailed description of the changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard for your commit messages. - **Good PR title:** `feat(cli): Add --json flag to 'config get' command` - **Bad PR title:** `Made some changes` In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`). ### Forking If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run you'll need to add a [GitHub Repository Secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) with a value of `GEMINI_API_KEY` and set that to a valid API key that you have available. Your key and secret are private to your repo; no one without access can see your key and you cannot see any secrets related to this repo. Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen. ### Development setup and workflow This section guides contributors on how to build, modify, and understand the development setup of this project. ### Setting up the development environment **Prerequisites:** 1. **Node.js**: - **Development:** Please use Node.js `~20.19.0`. This specific version is required due to an upstream development dependency issue. You can use a tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions. - **Production:** For running the CLI in a production environment, any version of Node.js `>=20` is acceptable. 2. **Git** ### Build process To clone the repository: ```bash git clone https://github.com/google-gemini/gemini-cli.git # Or your fork's URL cd gemini-cli ``` To install dependencies defined in `package.json` as well as root dependencies: ```bash npm install ``` To build the entire project (all packages): ```bash npm run build ``` This command typically compiles TypeScript to JavaScript, bundles assets, and prepares the packages for execution. Refer to `scripts/build.js` and `package.json` scripts for more details on what happens during the build. ### Enabling sandboxing [Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details. To build both the `gemini` CLI utility and the sandbox container, run `build:all` from the root directory: ```bash npm run build:all ``` To skip building the sandbox container, you can use `npm run build` instead. ### Running the CLI To start the Gemini CLI from the source code (after building), run the following command from the root directory: ```bash npm start ``` If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini` ### Running tests This project contains two types of tests: unit tests and integration tests. #### Unit tests To execute the unit test suite for the project: ```bash npm run test ``` This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`. #### Integration tests The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command. To run the integration tests, use the following command: ```bash npm run test:e2e ``` For more detailed information on the integration testing framework, please see the [Integration Tests documentation](https://geminicli.com/docs/integration-tests). ### Linting and preflight checks To ensure code quality and formatting consistency, run the preflight check: ```bash npm run preflight ``` This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`. _ProTip_ after cloning create a git precommit hook file to ensure your commits are always clean. ```bash echo " # Run npm build and check for errors if ! npm run preflight; then echo "npm build failed. Commit aborted." exit 1 fi " > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit ``` #### Formatting To separately format the code in this project by running the following command from the root directory: ```bash npm run format ``` This command uses Prettier to format the code according to the project's style guidelines. #### Linting To separately lint the code in this project, run the following command from the root directory: ```bash npm run lint ``` ### Coding conventions - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. - Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage. - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. ### Debugging #### VS Code 0. Run the CLI to interactively debug in VS Code with `F5` 1. Start the CLI in debug mode from the root directory: ```bash npm run debug ``` This command runs `node --inspect-brk dist/gemini.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. 2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended. To hit a breakpoint inside the sandbox container run: ```bash DEBUG=1 gemini ``` **Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. ### React DevTools To debug the CLI's React-based UI, you can use React DevTools. 1. **Start the Gemini CLI in development mode:** ```bash DEV=true npm start ``` 2. **Install and run React DevTools version 6 (which matches the CLI's `react-devtools-core`):** You can either install it globally: ```bash npm install -g react-devtools@6 react-devtools ``` Or run it directly using npx: ```bash npx react-devtools@6 ``` Your running CLI application should then connect to React DevTools. ![](/docs/assets/connected_devtools.png) ### Sandboxing #### macOS Seatbelt On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `strict-open` profile (see `packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads and writes to the working directory while allowing outbound network traffic by setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file. Available built-in profiles are `permissive-{open,proxied}`, `restrictive-{open,proxied}`, and `strict-{open,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=` if you also create a file `.gemini/sandbox-macos-.sb` under your project settings directory `.gemini`. #### Container-based sandboxing (all platforms) For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox. Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.gemini/sandbox.Dockerfile` and/or `.gemini/sandbox.bashrc` under your project settings directory (`.gemini`) and running `gemini` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox. #### Proxied networking All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=`, where `` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox. ### Manual publish We publish an artifact for each commit to our internal registry. But if you need to manually cut a local build, then run the following commands: ``` npm run clean npm install npm run auth npm run prerelease:dev npm publish --workspaces ``` ## Documentation contribution process Our documentation must be kept up-to-date with our code contributions. We want our documentation to be clear, concise, and helpful to our users. We value: - **Clarity:** Use simple and direct language. Avoid jargon where possible. - **Accuracy:** Ensure all information is correct and up-to-date. - **Completeness:** Cover all aspects of a feature or topic. - **Examples:** Provide practical examples to help users understand how to use Gemini CLI. ### Getting started The process for contributing to the documentation is similar to contributing code. 1. **Fork the repository** and create a new branch. 2. **Make your changes** in the `/docs` directory. 3. **Preview your changes locally** in Markdown rendering. 4. **Lint and format your changes.** Our preflight check includes linting and formatting for documentation files. ```bash npm run preflight ``` 5. **Open a pull request** with your changes. ### Documentation structure Our documentation is organized using [sidebar.json](/docs/sidebar.json) as the table of contents. When adding new documentation: 1. Create your markdown file **in the appropriate directory** under `/docs`. 2. Add an entry to `sidebar.json` in the relevant section. 3. Ensure all internal links use relative paths and point to existing files. ### Style guide We follow the [Google Developer Documentation Style Guide](https://developers.google.com/style). Please refer to it for guidance on writing style, tone, and formatting. #### Key style points - Use sentence case for headings. - Write in second person ("you") when addressing the reader. - Use present tense. - Keep paragraphs short and focused. - Use code blocks with appropriate language tags for syntax highlighting. - Include practical examples whenever possible. ### Linting and formatting We use `prettier` to enforce a consistent style across our documentation. The `npm run preflight` command will check for any linting issues. You can also run the linter and formatter separately: - `npm run lint` - Check for linting issues - `npm run format` - Auto-format markdown files - `npm run lint:fix` - Auto-fix linting issues where possible Please make sure your contributions are free of linting errors before submitting a pull request. ### Before you submit Before submitting your documentation pull request, please: 1. Run `npm run preflight` to ensure all checks pass. 2. Review your changes for clarity and accuracy. 3. Check that all links work correctly. 4. Ensure any code examples are tested and functional. 5. Sign the [Contributor License Agreement (CLA)](https://cla.developers.google.com/) if you haven't already. ### Need help? If you have questions about contributing documentation: - Check our [FAQ](https://geminicli.com/docs/resources/faq). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. - Reach out to the maintainers. We appreciate your contributions to making Gemini CLI documentation better! ================================================ FILE: Dockerfile ================================================ FROM docker.io/library/node:20-slim ARG SANDBOX_NAME="gemini-cli-sandbox" ARG CLI_VERSION_ARG ENV SANDBOX="$SANDBOX_NAME" ENV CLI_VERSION=$CLI_VERSION_ARG # install minimal set of packages, then clean up RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ make \ g++ \ man-db \ curl \ dnsutils \ less \ jq \ bc \ gh \ git \ unzip \ rsync \ ripgrep \ procps \ psmisc \ lsof \ socat \ ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # set up npm global package folder under /usr/local/share # give it to non-root user node, already set up in base image RUN mkdir -p /usr/local/share/npm-global \ && chown -R node:node /usr/local/share/npm-global ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin # switch to non-root user node USER node # install gemini-cli and clean up COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz RUN npm install -g /tmp/gemini-core.tgz \ && npm install -g /tmp/gemini-cli.tgz \ && node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \ && gemini --version > /dev/null \ && npm cache clean --force \ && rm -f /tmp/gemini-{cli,core}.tgz # default entrypoint when none specified CMD ["gemini"] ================================================ FILE: GEMINI.md ================================================ # Gemini CLI Project Context Gemini CLI is an open-source AI agent that brings the power of Gemini directly into the terminal. It is designed to be a terminal-first, extensible, and powerful tool for developers. ## Project Overview - **Purpose:** Provide a seamless terminal interface for Gemini models, supporting code understanding, generation, automation, and integration via MCP (Model Context Protocol). - **Main Technologies:** - **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development) - **Language:** TypeScript - **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink) for CLI rendering) - **Testing:** Vitest - **Bundling:** esbuild - **Linting/Formatting:** ESLint, Prettier - **Architecture:** Monorepo structure using npm workspaces. - `packages/cli`: User-facing terminal UI, input processing, and display rendering. - `packages/core`: Backend logic, Gemini API orchestration, prompt construction, and tool execution. - `packages/a2a-server`: Experimental Agent-to-Agent server. - `packages/sdk`: Programmatic SDK for embedding Gemini CLI capabilities. - `packages/devtools`: Integrated developer tools (Network/Console inspector). - `packages/test-utils`: Shared test utilities and test rig. - `packages/vscode-ide-companion`: VS Code extension pairing with the CLI. ## Building and Running - **Install Dependencies:** `npm install` - **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code companion) - **Build Packages:** `npm run build` - **Run in Development:** `npm run start` - **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector) - **Bundle Project:** `npm run bundle` - **Clean Artifacts:** `npm run clean` ## Testing and Quality - **Test Commands:** - **Unit (All):** `npm run test` - **Integration (E2E):** `npm run test:e2e` - **Workspace-Specific:** `npm test -w -- ` (Note: `` must be relative to the workspace root, e.g., `-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`) - **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install, build, lint, type check, and tests. Recommended before submitting PRs. Due to its long runtime, only run this at the very end of a code implementation task. If it fails, use faster, targeted commands (e.g., `npm run test`, `npm run lint`, or workspace-specific tests) to iterate on fixes before re-running `preflight`. For simple, non-code changes like documentation or prompting updates, skip `preflight` at the end of the task and wait for PR validation.) - **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck` ## Development Conventions - **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires signing the Google CLA. - **Pull Requests:** Keep PRs small, focused, and linked to an existing issue. Always activate the `pr-creator` skill for PR generation, even when using the `gh` CLI. - **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard. - **Imports:** Use specific imports and avoid restricted relative imports between packages (enforced by ESLint). - **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`), include the Apache-2.0 license header with the current year. (e.g., `Copyright 2026 Google LLC`). This is enforced by ESLint. ## Testing Conventions - **Environment Variables:** When testing code that depends on environment variables, use `vi.stubEnv('NAME', 'value')` in `beforeEach` and `vi.unstubAllEnvs()` in `afterEach`. Avoid modifying `process.env` directly as it can lead to test leakage and is less reliable. To "unset" a variable, use an empty string `vi.stubEnv('NAME', '')`. ## Documentation - Always use the `docs-writer` skill when you are asked to write, edit, or review any documentation. - Documentation is located in the `docs/` directory. - Suggest documentation updates when code changes render existing documentation obsolete or incomplete. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ # Makefile for gemini-cli .PHONY: help install build build-sandbox build-all test lint format preflight clean start debug release run-npx create-alias help: @echo "Makefile for gemini-cli" @echo "" @echo "Usage:" @echo " make install - Install npm dependencies" @echo " make build - Build the main project" @echo " make build-all - Build the main project and sandbox" @echo " make test - Run the test suite" @echo " make lint - Lint the code" @echo " make format - Format the code" @echo " make preflight - Run formatting, linting, and tests" @echo " make clean - Remove generated files" @echo " make start - Start the Gemini CLI" @echo " make debug - Start the Gemini CLI in debug mode" @echo "" @echo " make run-npx - Run the CLI using npx (for testing the published package)" @echo " make create-alias - Create a 'gemini' alias for your shell" install: npm install build: npm run build build-all: npm run build:all test: npm run test lint: npm run lint format: npm run format preflight: npm run preflight clean: npm run clean start: npm run start debug: npm run debug run-npx: npx https://github.com/google-gemini/gemini-cli create-alias: scripts/create_alias.sh ================================================ FILE: README.md ================================================ # Gemini CLI [![Gemini CLI CI](https://github.com/google-gemini/gemini-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/ci.yml) [![Gemini CLI E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml) [![Version](https://img.shields.io/npm/v/@google/gemini-cli)](https://www.npmjs.com/package/@google/gemini-cli) [![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE) [![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli) ![Gemini CLI Screenshot](/docs/assets/gemini-screenshot.png) Gemini CLI is an open-source AI agent that brings the power of Gemini directly into your terminal. It provides lightweight access to Gemini, giving you the most direct path from your prompt to our model. Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). ## 🚀 Why Gemini CLI? - **🎯 Free tier**: 60 requests/min and 1,000 requests/day with personal Google account. - **🧠 Powerful Gemini 3 models**: Access to improved reasoning and 1M token context window. - **🔧 Built-in tools**: Google Search grounding, file operations, shell commands, web fetching. - **🔌 Extensible**: MCP (Model Context Protocol) support for custom integrations. - **💻 Terminal-first**: Designed for developers who live in the command line. - **🛡️ Open source**: Apache 2.0 licensed. ## 📦 Installation See [Gemini CLI installation, execution, and releases](./docs/get-started/installation.md) for recommended system specifications and a detailed installation guide. ### Quick Install #### Run instantly with npx ```bash # Using npx (no installation required) npx @google/gemini-cli ``` #### Install globally with npm ```bash npm install -g @google/gemini-cli ``` #### Install globally with Homebrew (macOS/Linux) ```bash brew install gemini-cli ``` #### Install globally with MacPorts (macOS) ```bash sudo port install gemini-cli ``` #### Install with Anaconda (for restricted environments) ```bash # Create and activate a new environment conda create -y -n gemini_env -c conda-forge nodejs conda activate gemini_env # Install Gemini CLI globally via npm (inside the environment) npm install -g @google/gemini-cli ``` ## Release Cadence and Tags See [Releases](./docs/releases.md) for more details. ### Preview New preview releases will be published each week at UTC 23:59 on Tuesdays. These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag. ```bash npm install -g @google/gemini-cli@preview ``` ### Stable - New stable releases will be published each week at UTC 20:00 on Tuesdays, this will be the full promotion of last week's `preview` release + any bug fixes and validations. Use `latest` tag. ```bash npm install -g @google/gemini-cli@latest ``` ### Nightly - New releases will be published each day at UTC 00:00. This will be all changes from the main branch as represented at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag. ```bash npm install -g @google/gemini-cli@nightly ``` ## 📋 Key Features ### Code Understanding & Generation - Query and edit large codebases - Generate new apps from PDFs, images, or sketches using multimodal capabilities - Debug issues and troubleshoot with natural language ### Automation & Integration - Automate operational tasks like querying pull requests or handling complex rebases - Use MCP servers to connect new capabilities, including [media generation with Imagen, Veo or Lyria](https://github.com/GoogleCloudPlatform/vertex-ai-creative-studio/tree/main/experiments/mcp-genmedia) - Run non-interactively in scripts for workflow automation ### Advanced Capabilities - Ground your queries with built-in [Google Search](https://ai.google.dev/gemini-api/docs/grounding) for real-time information - Conversation checkpointing to save and resume complex sessions - Custom context files (GEMINI.md) to tailor behavior for your projects ### GitHub Integration Integrate Gemini CLI directly into your GitHub workflows with [**Gemini CLI GitHub Action**](https://github.com/google-github-actions/run-gemini-cli): - **Pull Request Reviews**: Automated code review with contextual feedback and suggestions - **Issue Triage**: Automated labeling and prioritization of GitHub issues based on content analysis - **On-demand Assistance**: Mention `@gemini-cli` in issues and pull requests for help with debugging, explanations, or task delegation - **Custom Workflows**: Build automated, scheduled and on-demand workflows tailored to your team's needs ## 🔐 Authentication Options Choose the authentication method that best fits your needs: ### Option 1: Sign in with Google (OAuth login using your Google Account) **✨ Best for:** Individual developers as well as anyone who has a Gemini Code Assist License. (see [quota limits and terms of service](https://cloud.google.com/gemini/docs/quotas) for details) **Benefits:** - **Free tier**: 60 requests/min and 1,000 requests/day - **Gemini 3 models** with 1M token context window - **No API key management** - just sign in with your Google account - **Automatic updates** to latest models #### Start Gemini CLI, then choose _Sign in with Google_ and follow the browser authentication flow when prompted ```bash gemini ``` #### If you are using a paid Code Assist License from your organization, remember to set the Google Cloud Project ```bash # Set your Google Cloud Project export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" gemini ``` ### Option 2: Gemini API Key **✨ Best for:** Developers who need specific model control or paid tier access **Benefits:** - **Free tier**: 1000 requests/day with Gemini 3 (mix of flash and pro) - **Model selection**: Choose specific Gemini models - **Usage-based billing**: Upgrade for higher limits when needed ```bash # Get your key from https://aistudio.google.com/apikey export GEMINI_API_KEY="YOUR_API_KEY" gemini ``` ### Option 3: Vertex AI **✨ Best for:** Enterprise teams and production workloads **Benefits:** - **Enterprise features**: Advanced security and compliance - **Scalable**: Higher rate limits with billing account - **Integration**: Works with existing Google Cloud infrastructure ```bash # Get your key from Google Cloud Console export GOOGLE_API_KEY="YOUR_API_KEY" export GOOGLE_GENAI_USE_VERTEXAI=true gemini ``` For Google Workspace accounts and other authentication methods, see the [authentication guide](./docs/get-started/authentication.md). ## 🚀 Getting Started ### Basic Usage #### Start in current directory ```bash gemini ``` #### Include multiple directories ```bash gemini --include-directories ../lib,../docs ``` #### Use specific model ```bash gemini -m gemini-2.5-flash ``` #### Non-interactive mode for scripts Get a simple text response: ```bash gemini -p "Explain the architecture of this codebase" ``` For more advanced scripting, including how to parse JSON and handle errors, use the `--output-format json` flag to get structured output: ```bash gemini -p "Explain the architecture of this codebase" --output-format json ``` For real-time event streaming (useful for monitoring long-running operations), use `--output-format stream-json` to get newline-delimited JSON events: ```bash gemini -p "Run tests and deploy" --output-format stream-json ``` ### Quick Examples #### Start a new project ```bash cd new-project/ gemini > Write me a Discord bot that answers questions using a FAQ.md file I will provide ``` #### Analyze existing code ```bash git clone https://github.com/google-gemini/gemini-cli cd gemini-cli gemini > Give me a summary of all of the changes that went in yesterday ``` ## 📚 Documentation ### Getting Started - [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running quickly. - [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed auth configuration. - [**Configuration Guide**](./docs/reference/configuration.md) - Settings and customization. - [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - Productivity tips. ### Core Features - [**Commands Reference**](./docs/reference/commands.md) - All slash commands (`/help`, `/chat`, etc). - [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own reusable commands. - [**Context Files (GEMINI.md)**](./docs/cli/gemini-md.md) - Provide persistent context to Gemini CLI. - [**Checkpointing**](./docs/cli/checkpointing.md) - Save and resume conversations. - [**Token Caching**](./docs/cli/token-caching.md) - Optimize token usage. ### Tools & Extensions - [**Built-in Tools Overview**](./docs/reference/tools.md) - [File System Operations](./docs/tools/file-system.md) - [Shell Commands](./docs/tools/shell.md) - [Web Fetch & Search](./docs/tools/web-fetch.md) - [**MCP Server Integration**](./docs/tools/mcp-server.md) - Extend with custom tools. - [**Custom Extensions**](./docs/extensions/index.md) - Build and share your own commands. ### Advanced Topics - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. - [**Trusted Folders**](./docs/cli/trusted-folders.md) - Control execution policies by folder. - [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. - [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview. - [**Local development**](./docs/local-development.md) - Local development tooling. ### Troubleshooting & Support - [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common issues and solutions. - [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers Configure MCP servers in `~/.gemini/settings.json` to extend Gemini CLI with custom tools: ```text > @github List my open pull requests > @slack Send a summary of today's commits to #dev channel > @database Run a query to find inactive users ``` See the [MCP Server Integration guide](./docs/tools/mcp-server.md) for setup instructions. ## 🤝 Contributing We welcome contributions! Gemini CLI is fully open source (Apache 2.0), and we encourage the community to: - Report bugs and suggest features. - Improve documentation. - Submit code improvements. - Share your MCP servers and extensions. See our [Contributing Guide](./CONTRIBUTING.md) for development setup, coding standards, and how to submit pull requests. Check our [Official Roadmap](https://github.com/orgs/google-gemini/projects/11) for planned features and priorities. ## 📖 Resources - **[Official Roadmap](./ROADMAP.md)** - See what's coming next. - **[Changelog](./docs/changelogs/index.md)** - See recent notable updates. - **[NPM Package](https://www.npmjs.com/package/@google/gemini-cli)** - Package registry. - **[GitHub Issues](https://github.com/google-gemini/gemini-cli/issues)** - Report bugs or request features. - **[Security Advisories](https://github.com/google-gemini/gemini-cli/security/advisories)** - Security updates. ### Uninstall See the [Uninstall Guide](./docs/resources/uninstall.md) for removal instructions. ## 📄 Legal - **License**: [Apache License 2.0](LICENSE) - **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md) - **Security**: [Security Policy](SECURITY.md) ---

Built with ❤️ by Google and the open source community

================================================ FILE: ROADMAP.md ================================================ # Gemini CLI Roadmap The [Official Gemini CLI Roadmap](https://github.com/orgs/google-gemini/projects/11/) Gemini CLI is an open-source AI agent that brings the power of Gemini directly into your terminal. It provides lightweight access to Gemini, giving you the most direct path from your prompt to our model. This document outlines our approach to the Gemini CLI roadmap. Here, you'll find our guiding principles and a breakdown of the key areas we are focused on for development. Our roadmap is not a static list but a dynamic set of priorities that are tracked live in our GitHub Issues. As an [Apache 2.0 open source project](https://github.com/google-gemini/gemini-cli?tab=Apache-2.0-1-ov-file#readme), we appreciate and welcome [public contributions](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md), and will give first priority to those contributions aligned with our roadmap. If you want to propose a new feature or change to our roadmap, please start by [opening an issue for discussion](https://github.com/google-gemini/gemini-cli/issues/new/choose). ## Disclaimer This roadmap represents our current thinking and is for informational purposes only. It is not a commitment or a guarantee of future delivery. The development, release, and timing of any features are subject to change, and we may update the roadmap based on community discussions as well as when our priorities evolve. ## Guiding Principles Our development is guided by the following principles: - **Power & Simplicity:** Deliver access to state-of-the-art Gemini models with an intuitive and easy-to-use lightweight command-line interface. - **Extensibility:** An adaptable agent to help you with a variety of use cases and environments along with the ability to run these agents anywhere. - **Intelligent:** Gemini CLI should be reliably ranked among the best agentic tools as measured by benchmarks like SWE Bench, Terminal Bench, and CSAT. - **Free and Open Source:** Foster a thriving open source community where cost isn’t a barrier to personal use, and PRs get merged quickly. This means resolving and closing issues, pull requests, and discussion posts quickly. ## How the Roadmap Works Our roadmap is managed directly through GitHub Issues. See our entry point Roadmap Issue [here](https://github.com/google-gemini/gemini-cli/issues/4191). This approach allows for transparency and gives you a direct way to learn more or get involved with any specific initiative. All our roadmap items will be tagged as Type:`Feature` and Label:`maintainer` for features we are actively working on, or Type:`Task` and Label:`maintainer` for a more detailed list of tasks. Issues are organized to provide key information at a glance: - **Target Quarter:** `Milestone` denotes the anticipated delivery timeline. - **Feature Area:** Labels such as `area/model` or `area/tooling` categorize the work. - **Issue Type:** _Workstream_ => _Epics_ => _Features_ => _Tasks|Bugs_ To see what we're working on, you can filter our issues by these dimensions. See all our items [here](https://github.com/orgs/google-gemini/projects/11/views/19) ## Focus Areas To better organize our efforts, we categorize our work into several key feature areas. These labels are used on our GitHub Issues to help you filter and find initiatives that interest you. - **Authentication:** Secure user access via API keys, Gemini Code Assist login, etc. - **Model:** Support new Gemini models, multi-modality, local execution, and performance tuning. - **User Experience:** Improve the CLI's usability, performance, interactive features, and documentation. - **Tooling:** Built-in tools and the MCP ecosystem. - **Core:** Core functionality of the CLI - **Extensibility:** Bringing Gemini CLI to other surfaces e.g. GitHub. - **Contribution:** Improve the contribution process via test automation and CI/CD pipeline enhancements. - **Platform:** Manage installation, OS support, and the underlying CLI framework. - **Quality:** Focus on testing, reliability, performance, and overall product quality. - **Background Agents:** Enable long-running, autonomous tasks and proactive assistance. - **Security and Privacy:** For all things related to security and privacy ## How to Contribute Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved: - **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with. - **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`. - **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml). - **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors. - **Write Documentation:** Help us improve our documentation, tutorials, and examples. We are excited about the future of Gemini CLI and look forward to building it with you! ================================================ FILE: SECURITY.md ================================================ # Reporting Security Issues To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz). We use g.co/vulnz for our intake, and do coordination and disclosure here on GitHub (including using GitHub Security Advisory). The Google Security Team will respond within 5 working days of your report on g.co/vulnz. [GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories ================================================ FILE: docs/admin/enterprise-controls.md ================================================ # Enterprise Admin Controls Gemini CLI empowers enterprise administrators to manage and enforce security policies and configuration settings across their entire organization. Secure defaults are enabled automatically for all enterprise users, but can be customized via the [Management Console](https://goo.gle/manage-gemini-cli). **Enterprise Admin Controls are enforced globally and cannot be overridden by users locally**, ensuring a consistent security posture. ## Admin Controls vs. System Settings While [System-wide settings](../cli/settings.md) act as convenient configuration overrides, they can still be modified by users with sufficient privileges. In contrast, admin controls are immutable at the local level, making them the preferred method for enforcing policy. ## Available Controls ### Strict Mode **Enabled/Disabled** | Default: enabled If enabled, users will not be able to enter yolo mode. ### Extensions **Enabled/Disabled** | Default: disabled If disabled, users will not be able to use or install extensions. See [Extensions](../extensions/index.md) for more details. ### MCP #### Enabled/Disabled **Enabled/Disabled** | Default: disabled If disabled, users will not be able to use MCP servers. See [MCP Server Integration](../tools/mcp-server.md) for more details. #### MCP Servers (preview) **Default**: empty Allows administrators to define an explicit allowlist of MCP servers. This guarantees that users can only connect to trusted MCP servers defined by the organization. **Allowlist Format:** ```json { "mcpServers": { "external-provider": { "url": "https://api.mcp-provider.com", "type": "sse", "trust": true, "includeTools": ["toolA", "toolB"], "excludeTools": [] }, "internal-corp-tool": { "url": "https://mcp.internal-tool.corp", "type": "http", "includeTools": [], "excludeTools": ["adminTool"] } } } ``` **Supported Fields:** - `url`: (Required) The full URL of the MCP server endpoint. - `type`: (Required) The connection type (e.g., `sse` or `http`). - `trust`: (Optional) If set to `true`, the server is trusted and tool execution will not require user approval. - `includeTools`: (Optional) An explicit list of tool names to allow. If specified, only these tools will be available. - `excludeTools`: (Optional) A list of tool names to hide. These tools will be blocked. **Client Enforcement Logic:** - **Empty Allowlist**: If the admin allowlist is empty, the client uses the user’s local configuration as is (unless the MCP toggle above is disabled). - **Active Allowlist**: If the allowlist contains one or more servers, **all locally configured servers not present in the allowlist are ignored**. - **Configuration Merging**: For a server to be active, it must exist in **both** the admin allowlist and the user’s local configuration (matched by name). The client merges these definitions as follows: - **Override Fields**: The `url`, `type`, & `trust` are always taken from the admin allowlist, overriding any local values. - **Tools Filtering**: If `includeTools` or `excludeTools` are defined in the allowlist, the admin’s rules are used exclusively. If both are undefined in the admin allowlist, the client falls back to the user’s local tool settings. - **Cleared Fields**: To ensure security and consistency, the client automatically clears local execution fields (`command`, `args`, `env`, `cwd`, `httpUrl`, `tcp`). This prevents users from overriding the connection method. - **Other Fields**: All other MCP fields are pulled from the user’s local configuration. - **Missing Allowlisted Servers**: If a server appears in the admin allowlist but is missing from the local configuration, it will not be initialized. This ensures users maintain final control over which permitted servers are actually active in their environment. #### Required MCP Servers (preview) **Default**: empty Allows administrators to define MCP servers that are **always injected** into the user's environment. Unlike the allowlist (which filters user-configured servers), required servers are automatically added regardless of the user's local configuration. **Required Servers Format:** ```json { "requiredMcpServers": { "corp-compliance-tool": { "url": "https://mcp.corp/compliance", "type": "http", "trust": true, "description": "Corporate compliance tool" }, "internal-registry": { "url": "https://registry.corp/mcp", "type": "sse", "authProviderType": "google_credentials", "oauth": { "scopes": ["https://www.googleapis.com/auth/scope"] } } } } ``` **Supported Fields:** - `url`: (Required) The full URL of the MCP server endpoint. - `type`: (Required) The connection type (`sse` or `http`). - `trust`: (Optional) If set to `true`, tool execution will not require user approval. Defaults to `true` for required servers. - `description`: (Optional) Human-readable description of the server. - `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, `google_credentials`, or `service_account_impersonation`). - `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and `clientSecret`. - `targetAudience`: (Optional) OAuth target audience for service-to-service auth. - `targetServiceAccount`: (Optional) Service account email to impersonate. - `headers`: (Optional) Additional HTTP headers to send with requests. - `includeTools` / `excludeTools`: (Optional) Tool filtering lists. - `timeout`: (Optional) Timeout in milliseconds for MCP requests. **Client Enforcement Logic:** - Required servers are injected **after** allowlist filtering, so they are always available even if the allowlist is active. - If a required server has the **same name** as a locally configured server, the admin configuration **completely overrides** the local one. - Required servers only support remote transports (`sse`, `http`). Local execution fields (`command`, `args`, `env`, `cwd`) are not supported. - Required servers can coexist with allowlisted servers — both features work independently. ### Unmanaged Capabilities **Enabled/Disabled** | Default: disabled If disabled, users will not be able to use certain features. Currently, this control disables Agent Skills. See [Agent Skills](../cli/skills.md) for more details. ================================================ FILE: docs/changelogs/index.md ================================================ # Gemini CLI release notes Gemini CLI has three major release channels: nightly, preview, and stable. For most users, we recommend the stable release. On this page, you can find information regarding the current releases and announcements from each release. For the full changelog, refer to [Releases - google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli/releases) on GitHub. ## Current releases | Release channel | Notes | | :-------------------- | :---------------------------------------------- | | Nightly | Nightly release with the most recent changes. | | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | ## Announcements: v0.34.0 - 2026-03-17 - **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help you break down complex tasks and execute them systematically ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). - **Sandboxing Enhancements:** We've added native gVisor (runsc) and experimental LXC container sandboxing support for safer execution environments ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) by @h30s). ## Announcements: v0.33.0 - 2026-03-11 - **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A remote agents and authenticated A2A agent card discovery ([#20510](https://github.com/google-gemini/gemini-cli/pull/20510) by @SandyTao520, [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) by @SandyTao520). - **Plan Mode Updates:** Expanded Plan Mode with built-in research subagents, annotation support for feedback, and a new `copy` subcommand ([#20972](https://github.com/google-gemini/gemini-cli/pull/20972) by @Adib234, [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) by @ruomengz). - **CLI UX & Admin Controls:** Redesigned the header to be compact with an ASCII icon, inverted context window display to show usage, and enabled a 30-day default retention for chat history ([#18713](https://github.com/google-gemini/gemini-cli/pull/18713) by @keithguerin, [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) by @skeshive). ## Announcements: v0.32.0 - 2026-03-03 - **Generalist Agent:** The generalist agent is now enabled to improve task delegation and routing ([#19665](https://github.com/google-gemini/gemini-cli/pull/19665) by @joshualitt). - **Model Steering in Workspace:** Added support for model steering directly in the workspace ([#20343](https://github.com/google-gemini/gemini-cli/pull/20343) by @joshualitt). - **Plan Mode Enhancements:** Users can now open and modify plans in an external editor, and the planning workflow has been adapted to handle complex tasks more effectively with multi-select options ([#20348](https://github.com/google-gemini/gemini-cli/pull/20348) by @Adib234, [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) by @jerop). - **Interactive Shell Autocompletion:** Introduced interactive shell autocompletion for a more seamless experience ([#20082](https://github.com/google-gemini/gemini-cli/pull/20082) by @mrpmohiburrahman). - **Parallel Extension Loading:** Extensions are now loaded in parallel to improve startup times ([#20229](https://github.com/google-gemini/gemini-cli/pull/20229) by @scidomino). ## Announcements: v0.31.0 - 2026-02-27 - **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro Preview model ([#19676](https://github.com/google-gemini/gemini-cli/pull/19676) by @sehoon38). - **Experimental Browser Agent:** We've introduced a new experimental browser agent to interact with web pages ([#19284](https://github.com/google-gemini/gemini-cli/pull/19284) by @gsquared94). - **Policy Engine Updates:** The policy engine now supports project-level policies, MCP server wildcards, and tool annotation matching ([#18682](https://github.com/google-gemini/gemini-cli/pull/18682) by @Abhijit-2592, [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) by @jerop). - **Web Fetch Improvements:** We've implemented an experimental direct web fetch feature and added rate limiting to mitigate DDoS risks ([#19557](https://github.com/google-gemini/gemini-cli/pull/19557) by @mbleigh, [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) by @mattKorwel). ## Announcements: v0.30.0 - 2026-02-25 - **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic system instructions, `SessionContext` for SDK tool calls, and support for custom skills ([#18861](https://github.com/google-gemini/gemini-cli/pull/18861) by @mbleigh). - **Policy Engine Enhancements:** Added a new `--policy` flag for user-defined policies, introduced strict seatbelt profiles, and deprecated `--allowed-tools` in favor of the policy engine ([#18500](https://github.com/google-gemini/gemini-cli/pull/18500) by @allenhutchison). - **UI & Themes:** Added a generic searchable list for settings and extensions, new Solarized themes, text wrapping for markdown tables, and a clean UI toggle prototype ([#19064](https://github.com/google-gemini/gemini-cli/pull/19064) by @rmedranollamas). - **Vim & Terminal Interaction:** Improved Vim support to feel more complete and added support for Ctrl-Z terminal suspension ([#18755](https://github.com/google-gemini/gemini-cli/pull/18755) by @ppgranger, [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) by @scidomino). ## Announcements: v0.29.0 - 2026-02-17 - **Plan Mode:** A new comprehensive planning capability with `/plan`, `enter_plan_mode` tool, and dedicated documentation ([#17698](https://github.com/google-gemini/gemini-cli/pull/17698) by @Adib234, [#18324](https://github.com/google-gemini/gemini-cli/pull/18324) by @jerop). - **Gemini 3 Default:** We've removed the preview flag and enabled Gemini 3 by default for all users ([#18414](https://github.com/google-gemini/gemini-cli/pull/18414) by @sehoon38). - **Extension Exploration:** New UI and settings to explore and manage extensions more easily ([#18686](https://github.com/google-gemini/gemini-cli/pull/18686) by @sripasg). - **Admin Control:** Administrators can now allowlist specific MCP server configurations ([#18311](https://github.com/google-gemini/gemini-cli/pull/18311) by @skeshive). ## Announcements: v0.28.0 - 2026-02-10 - **IDE Support:** Gemini CLI now supports the Positron IDE ([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by @kapsner). - **Customization:** You can now use custom themes in extensions, and we've implemented automatic theme switching based on your terminal's background ([#17327](https://github.com/google-gemini/gemini-cli/pull/17327) by @spencer426, [#17976](https://github.com/google-gemini/gemini-cli/pull/17976) by @Abhijit-2592). - **Authentication:** We've added interactive and non-interactive consent for OAuth, and you can now include your auth method in bug reports ([#17699](https://github.com/google-gemini/gemini-cli/pull/17699) by @ehedlund, [#17569](https://github.com/google-gemini/gemini-cli/pull/17569) by @erikus). ## Announcements: v0.27.0 - 2026-02-03 - **Event-Driven Architecture:** The CLI now uses a new event-driven scheduler for tool execution, resulting in a more responsive and performant experience ([#17078](https://github.com/google-gemini/gemini-cli/pull/17078) by @abhipatel12). - **Enhanced User Experience:** This release includes queued tool confirmations, and expandable large text pastes for a smoother workflow. - **New `/rewind` Command:** Easily navigate your session history with the new `/rewind` command ([#15720](https://github.com/google-gemini/gemini-cli/pull/15720) by @Adib234). - **Linux Clipboard Support:** You can now paste images on Linux with Wayland and X11 ([#17144](https://github.com/google-gemini/gemini-cli/pull/17144) by @devr0306). ## Announcements: v0.26.0 - 2026-01-27 - **Agents and Skills:** We've introduced a new `skill-creator` skill ([#16394](https://github.com/google-gemini/gemini-cli/pull/16394) by @NTaylorMullen), enabled agent skills by default, and added a generalist agent to improve task routing ([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by @joshualitt). - **UI/UX Improvements:** You can now "Rewind" through your conversation history ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by @Adib234). - **Core and Scheduler Refactoring:** The core scheduler has been significantly refactored to improve performance and reliability ([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by @abhipatel12), and numerous performance and stability fixes have been included. ## Announcements: v0.25.0 - 2026-01-20 - **Skills and Agents Improvements:** We've enhanced the `activate_skill` tool, added a new `pr-creator` skill ([#16232](https://github.com/google-gemini/gemini-cli/pull/16232) by [@NTaylorMullen](https://github.com/NTaylorMullen)), enabled skills by default, improved the `cli_help` agent ([#16100](https://github.com/google-gemini/gemini-cli/pull/16100) by [@scidomino](https://github.com/scidomino)), and added a new `/agents refresh` command ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by [@joshualitt](https://github.com/joshualitt)). - **UI/UX Refinements:** You'll notice more transparent feedback for skills ([#15954](https://github.com/google-gemini/gemini-cli/pull/15954) by [@NTaylorMullen](https://github.com/NTaylorMullen)), the ability to switch focus between the shell and input with Tab ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by [@jacob314](https://github.com/jacob314)), and dynamic terminal tab titles ([#16378](https://github.com/google-gemini/gemini-cli/pull/16378) by [@NTaylorMullen](https://github.com/NTaylorMullen)). - **Core Functionality & Performance:** This release includes support for built-in agent skills ([#16045](https://github.com/google-gemini/gemini-cli/pull/16045) by [@NTaylorMullen](https://github.com/NTaylorMullen)), refined Gemini 3 system instructions ([#16139](https://github.com/google-gemini/gemini-cli/pull/16139) by [@NTaylorMullen](https://github.com/NTaylorMullen)), caching for ignore instances to improve performance ([#16185](https://github.com/google-gemini/gemini-cli/pull/16185) by [@EricRahm](https://github.com/EricRahm)), and enhanced retry mechanisms ([#16489](https://github.com/google-gemini/gemini-cli/pull/16489) by [@sehoon38](https://github.com/sehoon38)). - **Bug Fixes and Stability:** We've squashed numerous bugs across the CLI, core, and workflows, addressing issues with subagent delegation, unicode character crashes, and sticky header regressions. ## Announcements: v0.24.0 - 2026-01-14 - **Agent Skills:** We've introduced significant advancements in Agent Skills. This includes initial documentation and tutorials to help you get started, alongside enhanced support for remote agents, allowing for more distributed and powerful automation within Gemini CLI. ([#15869](https://github.com/google-gemini/gemini-cli/pull/15869) by [@NTaylorMullen](https://github.com/NTaylorMullen)), ([#16013](https://github.com/google-gemini/gemini-cli/pull/16013) by [@adamweidman](https://github.com/adamweidman)) - **Improved UI/UX:** The user interface has received several updates, featuring visual indicators for hook execution, a more refined display for settings, and the ability to use the Tab key to effortlessly switch focus between the shell and input areas. ([#15408](https://github.com/google-gemini/gemini-cli/pull/15408) by [@abhipatel12](https://github.com/abhipatel12)), ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by [@galz10](https://github.com/galz10)) - **Enhanced Security:** Security has been a major focus, with default folder trust now set to untrusted for increased safety. The Policy Engine has been improved to allow specific modes in user and administrator policies, and granular allowlisting for shell commands has been implemented, providing finer control over tool execution. ([#15943](https://github.com/google-gemini/gemini-cli/pull/15943) by [@galz10](https://github.com/galz10)), ([#15977](https://github.com/google-gemini/gemini-cli/pull/15977) by [@NTaylorMullen](https://github.com/NTaylorMullen)) - **Core Functionality:** This release includes a mandatory MessageBus injection, marking Phase 3 of a hard migration to a more robust internal communication system. We've also added support for built-in skills with the CLI itself, and enhanced model routing to effectively utilize subagents. ([#15776](https://github.com/google-gemini/gemini-cli/pull/15776) by [@abhipatel12](https://github.com/abhipatel12)), ([#16300](https://github.com/google-gemini/gemini-cli/pull/16300) by [@NTaylorMullen](https://github.com/NTaylorMullen)) - **Terminal Features:** Terminal interactions are more seamless with new features like OSC 52 paste support, along with fixes for Windows clipboard paste issues and general improvements to pasting in Windows terminals. ([#15336](https://github.com/google-gemini/gemini-cli/pull/15336) by [@scidomino](https://github.com/scidomino)), ([#15932](https://github.com/google-gemini/gemini-cli/pull/15932) by [@scidomino](https://github.com/scidomino)) - **New Commands:** To manage the new features, we've added several new commands: `/agents refresh` to update agent configurations, `/skills reload` to refresh skill definitions, and `/skills install/uninstall` for easier management of your Agent Skills. ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by [@NTaylorMullen](https://github.com/NTaylorMullen)), ([#15865](https://github.com/google-gemini/gemini-cli/pull/15865) by [@NTaylorMullen](https://github.com/NTaylorMullen)), ([#16377](https://github.com/google-gemini/gemini-cli/pull/16377) by [@NTaylorMullen](https://github.com/NTaylorMullen)) ## Announcements: v0.23.0 - 2026-01-07 - 🎉 **Experimental Agent Skills Support in Preview:** Gemini CLI now supports [Agent Skills](https://agentskills.io/home) in our preview builds. This is an early preview where we’re looking for feedback! - Install Preview: `npm install -g @google/gemini-cli@preview` - Enable in `/settings` - Docs: [https://geminicli.com/docs/cli/skills/](https://geminicli.com/docs/cli/skills/) - **Gemini CLI wrapped:** Run `npx gemini-wrapped` to visualize your usage stats, top models, languages, and more! - **Windows clipboard image support:** Windows users can now paste images directly from their clipboard into the CLI using `Alt`+`V`. ([pr](https://github.com/google-gemini/gemini-cli/pull/13997) by [@sgeraldes](https://github.com/sgeraldes)) - **Terminal background color detection:** Automatically optimizes your terminal's background color to select compatible themes and provide accessibility warnings. ([pr](https://github.com/google-gemini/gemini-cli/pull/15132) by [@jacob314](https://github.com/jacob314)) - **Session logout:** Use the new `/logout` command to instantly clear credentials and reset your authentication state for seamless account switching. ([pr](https://github.com/google-gemini/gemini-cli/pull/13383) by [@CN-Scars](https://github.com/CN-Scars)) ## Announcements: v0.22.0 - 2025-12-22 - 🎉**Free Tier + Gemini 3:** Free tier users now all have access to Gemini 3 Pro & Flash. Enable in `/settings` by toggling "Preview Features" to `true`. - 🎉**Gemini CLI + Colab:** Gemini CLI is now pre-installed. Can be used headlessly in notebook cells or interactively in the built-in terminal ([pic](https://imgur.com/a/G0Tn7vi)) - 🎉**Gemini CLI Extensions:** - **Conductor:** Planning++, Gemini works with you to build out a detailed plan, pull in extra details as needed, ultimately to give the LLM guardrails with artifacts. Measure twice, implement once! `gemini extensions install https://github.com/gemini-cli-extensions/conductor` Blog: [https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/](https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/) - **Endor Labs:** Perform code analysis, vulnerability scanning, and dependency checks using natural language. `gemini extensions install https://github.com/endorlabs/gemini-extension` ## Announcements: v0.21.0 - 2025-12-15 - **⚡️⚡️⚡️ Gemini 3 Flash + Gemini CLI:** Better, faster and cheaper than 2.5 Pro - and in some scenarios better than 3 Pro! For paid tiers + free tier users who were on the wait list enable **Preview Features** in `/settings.` - For more information: [Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/). - 🎉 Gemini CLI Extensions: - Rill: Utilize natural language to analyze Rill data, enabling the exploration of metrics and trends without the need for manual queries. `gemini extensions install https://github.com/rilldata/rill-gemini-extension` - Browserbase: Interact with web pages, take screenshots, extract information, and perform automated actions with atomic precision. `gemini extensions install https://github.com/browserbase/mcp-server-browserbase` - Quota Visibility: The `/stats` command now displays quota information for all available models, including those not used in the current session. (@sehoon38) - Fuzzy Setting Search: Users can now quickly find settings using fuzzy search within the settings dialog. (@sehoon38) - MCP Resource Support: Users can now discover, view, and search through resources using the @ command. (@MrLesk) - Auto-execute Simple Slash Commands: Simple slash commands are now executed immediately on enter. (@jackwotherspoon) ## Announcements: v0.20.0 - 2025-12-01 - **Multi-file Drag & Drop:** Users can now drag and drop multiple files into the terminal, and the CLI will automatically prefix each valid path with `@`. ([pr](https://github.com/google-gemini/gemini-cli/pull/14832) by [@jackwotherspoon](https://github.com/jackwotherspoon)) - **Persistent "Always Allow" Policies:** Users can now save "Always Allow" decisions for tool executions, with granular control over specific shell commands and multi-cloud platform tools. ([pr](https://github.com/google-gemini/gemini-cli/pull/14737) by [@allenhutchison](https://github.com/allenhutchison)) ## Announcements: v0.19.0 - 2025-11-24 - 🎉 **New extensions:** - **Eleven Labs:** Create, play, manage your audio play tracks with the Eleven Labs Gemini CLI extension: `gemini extensions install https://github.com/elevenlabs/elevenlabs-mcp` - **Zed integration:** Users can now leverage Gemini 3 within the Zed integration after enabling "Preview Features" in their CLI’s `/settings`. ([pr](https://github.com/google-gemini/gemini-cli/pull/13398) by [@benbrandt](https://github.com/benbrandt)) - **Interactive shell:** - **Click-to-Focus:** When "Use Alternate Buffer" setting is enabled, users can click within the embedded shell output to focus it for input. ([pr](https://github.com/google-gemini/gemini-cli/pull/13341) by [@galz10](https://github.com/galz10)) - **Loading phrase:** Clearly indicates when the interactive shell is awaiting user input. ([vid](https://imgur.com/a/kjK8bUK), [pr](https://github.com/google-gemini/gemini-cli/pull/12535) by [@jackwotherspoon](https://github.com/jackwotherspoon)) ## Announcements: v0.18.0 - 2025-11-17 - 🎉 **New extensions:** - **Google Workspace**: Integrate Gemini CLI with your Workspace data. Write docs, build slides, chat with others or even get your calc on in sheets: `gemini extensions install https://github.com/gemini-cli-extensions/workspace` - Blog: [https://allen.hutchison.org/2025/11/19/bringing-the-office-to-the-terminal/](https://allen.hutchison.org/2025/11/19/bringing-the-office-to-the-terminal/) - **Redis:** Manage and search data in Redis with natural language: `gemini extensions install https://github.com/redis/mcp-redis` - **Anomalo:** Query your data warehouse table metadata and quality status through commands and natural language: `gemini extensions install https://github.com/datagravity-ai/anomalo-gemini-extension` - **Experimental permission improvements:** We are now experimenting with a new policy engine in Gemini CLI. This allows users and administrators to create fine-grained policy for tool calls. Currently behind a flag. See [policy engine documentation](../reference/policy-engine.md) for more information. - Blog: [https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/) - **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API key, Google AI Pro or Google AI Ultra (for individuals, not businesses) and Gemini Code Assist Enterprise users. Enable it via `/settings` and toggling on **Preview Features**. - **Updated UI rollback:** We’ve temporarily rolled back our updated UI to give it more time to bake. This means for a time you won’t have embedded scrolling or mouse support. You can re-enable with `/settings` -> **Use Alternate Screen Buffer** -> `true`. - **Model in history:** Users can now toggle in `/settings` to display model in their chat history. ([gif](https://imgur.com/a/uEmNKnQ), [pr](https://github.com/google-gemini/gemini-cli/pull/13034) by [@scidomino](https://github.com/scidomino)) - **Multi-uninstall:** Users can now uninstall multiple extensions with a single command. ([pic](https://imgur.com/a/9Dtq8u2), [pr](https://github.com/google-gemini/gemini-cli/pull/13016) by [@JayadityaGit](https://github.com/JayadityaGit)) ## Announcements: v0.16.0 - 2025-11-10 - **Gemini 3 + Gemini CLI:** launch 🚀🚀🚀 - **Data Commons Gemini CLI Extension** - A new Data Commons Gemini CLI extension that lets you query open-source statistical data from datacommons.org. **To get started, you'll need a Data Commons API key and uv installed**. These and other details to get you started with the extension can be found at [https://github.com/gemini-cli-extensions/datacommons](https://github.com/gemini-cli-extensions/datacommons). ## Announcements: v0.15.0 - 2025-11-03 - **🎉 Seamless scrollable UI and mouse support:** We’ve given Gemini CLI a major facelift to make your terminal experience smoother and much more polished. You now get a flicker-free display with sticky headers that keep important context visible and a stable input prompt that doesn't jump around. We even added mouse support so you can click right where you need to type! ([gif](https://imgur.com/a/O6qc7bx), [@jacob314](https://github.com/jacob314)). - **Announcement:** [https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/) - **🎉 New partner extensions:** - **Arize:** Seamlessly instrument AI applications with Arize AX and grant direct access to Arize support: `gemini extensions install https://github.com/Arize-ai/arize-tracing-assistant` - **Chronosphere:** Retrieve logs, metrics, traces, events, and specific entities: `gemini extensions install https://github.com/chronosphereio/chronosphere-mcp` - **Transmit:** Comprehensive context, validation, and automated fixes for creating production-ready authentication and identity workflows: `gemini extensions install https://github.com/TransmitSecurity/transmit-security-journey-builder` - **Todo planning:** Complex questions now get broken down into todo lists that the model can manage and check off. ([gif](https://imgur.com/a/EGDfNlZ), [pr](https://github.com/google-gemini/gemini-cli/pull/12905) by [@anj-s](https://github.com/anj-s)) - **Disable GitHub extensions:** Users can now prevent the installation and loading of extensions from GitHub. ([pr](https://github.com/google-gemini/gemini-cli/pull/12838) by [@kevinjwang1](https://github.com/kevinjwang1)). - **Extensions restart:** Users can now explicitly restart extensions using the `/extensions restart` command. ([pr](https://github.com/google-gemini/gemini-cli/pull/12739) by [@jakemac53](https://github.com/jakemac53)). - **Better Angular support:** Angular workflows should now be more seamless ([pr](https://github.com/google-gemini/gemini-cli/pull/10252) by [@MarkTechson](https://github.com/MarkTechson)). - **Validate command:** Users can now check that local extensions are formatted correctly. ([pr](https://github.com/google-gemini/gemini-cli/pull/12186) by [@kevinjwang1](https://github.com/kevinjwang1)). ## Announcements: v0.12.0 - 2025-10-27 ![Codebase investigator subagent in Gemini CLI.](https://i.imgur.com/4J1njsx.png) - **🎉 New partner extensions:** - **🤗 Hugging Face extension:** Access the Hugging Face hub. ([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg)) `gemini extensions install https://github.com/huggingface/hf-mcp-server` - **Monday.com extension**: Analyze your sprints, update your task boards, etc. ([gif](https://drive.google.com/file/d/1cO0g6kY1odiBIrZTaqu5ZakaGZaZgpQv/view?usp=sharing&resourcekey=0-xEr67SIjXmAXRe1PKy7Jlw)) `gemini extensions install https://github.com/mondaycom/mcp` - **Data Commons extension:** Query public datasets or ground responses on data from Data Commons ([gif](https://drive.google.com/file/d/1cuj-B-vmUkeJnoBXrO_Y1CuqphYc6p-O/view?usp=sharing&resourcekey=0-0adXCXDQEd91ZZW63HbW-Q)). `gemini extensions install https://github.com/gemini-cli-extensions/datacommons` - **Model selection:** Choose the Gemini model for your session with `/model`. ([pic](https://imgur.com/a/ABFcWWw), [pr](https://github.com/google-gemini/gemini-cli/pull/8940) by [@abhipatel12](https://github.com/abhipatel12)). - **Model routing:** Gemini CLI will now intelligently pick the best model for the task. Simple queries will be sent to Flash while complex analytical or creative tasks will still use the power of Pro. This ensures your quota will last for a longer period of time. You can always opt-out of this via `/model`. ([pr](https://github.com/google-gemini/gemini-cli/pull/9262) by [@abhipatel12](https://github.com/abhipatel12)). - Discussion: [https://github.com/google-gemini/gemini-cli/discussions/12375](https://github.com/google-gemini/gemini-cli/discussions/12375) - **Codebase investigator subagent:** We now have a new built-in subagent that will explore your workspace and resolve relevant information to improve overall performance. ([pr](https://github.com/google-gemini/gemini-cli/pull/9988) by [@abhipatel12](https://github.com/abhipatel12), [pr](https://github.com/google-gemini/gemini-cli/pull/10282) by [@silviojr](https://github.com/silviojr)). - Enable, disable, or limit turns in `/settings`, plus advanced configs in `settings.json` ([pic](https://imgur.com/a/yJiggNO), [pr](https://github.com/google-gemini/gemini-cli/pull/10844) by [@silviojr](https://github.com/silviojr)). - **Explore extensions with `/extension`:** Users can now open the extensions page in their default browser directly from the CLI using the `/extension` explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) by [@JayadityaGit](https://github.com/JayadityaGit)). - **Configurable compression:** Users can modify the context compression threshold in `/settings` (decimal with percentage display). The default has been made more proactive ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by [@scidomino](https://github.com/scidomino)). - **API key authentication:** Users can now securely enter and store their Gemini API key via a new dialog, eliminating the need for environment variables and repeated entry. ([pr](https://github.com/google-gemini/gemini-cli/pull/11760) by [@galz10](https://github.com/galz10)). - **Sequential approval:** Users can now approve multiple tool calls sequentially during execution. ([pr](https://github.com/google-gemini/gemini-cli/pull/11593) by [@joshualitt](https://github.com/joshualitt)). ## Announcements: v0.11.0 - 2025-10-20 ![Gemini CLI and Jules](https://storage.googleapis.com/gweb-developer-goog-blog-assets/images/Jules_Extension_-_Blog_Header_O346JNt.original.png) - 🎉 **Gemini CLI Jules Extension:** Use Gemini CLI to orchestrate Jules. Spawn remote workers, delegate tedious tasks, or check in on running jobs! - Install: `gemini extensions install https://github.com/gemini-cli-extensions/jules` - Announcement: [https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/](https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/) - **Stream JSON output:** Stream real-time JSONL events with `--output-format stream-json` to monitor AI agent progress when run headlessly. ([gif](https://imgur.com/a/0UCE81X), [pr](https://github.com/google-gemini/gemini-cli/pull/10883) by [@anj-s](https://github.com/anj-s)) - **Markdown toggle:** Users can now switch between rendered and raw markdown display using `alt+m `or` ctrl+m`. ([gif](https://imgur.com/a/lDNdLqr), [pr](https://github.com/google-gemini/gemini-cli/pull/10383) by [@srivatsj](https://github.com/srivatsj)) - **Queued message editing:** Users can now quickly edit queued messages by pressing the up arrow key when the input is empty. ([gif](https://imgur.com/a/ioRslLd), [pr](https://github.com/google-gemini/gemini-cli/pull/10392) by [@akhil29](https://github.com/akhil29)) - **JSON web fetch**: Non-HTML content like JSON APIs or raw source code are now properly shown to the model (previously only supported HTML) ([gif](https://imgur.com/a/Q58U4qJ), [pr](https://github.com/google-gemini/gemini-cli/pull/11284) by [@abhipatel12](https://github.com/abhipatel12)) - **Non-interactive MCP commands:** Users can now run MCP slash commands in non-interactive mode `gemini "/some-mcp-prompt"`. ([pr](https://github.com/google-gemini/gemini-cli/pull/10194) by [@capachino](https://github.com/capachino)) - **Removal of deprecated flags:** We’ve finally removed a number of deprecated flags to cleanup Gemini CLI’s invocation profile: - `--all-files` / `-a` in favor of `@` from within Gemini CLI. ([pr](https://github.com/google-gemini/gemini-cli/pull/11228) by [@allenhutchison](https://github.com/allenhutchison)) - `--telemetry-*` flags in favor of [environment variables](https://github.com/google-gemini/gemini-cli/pull/11318) ([pr](https://github.com/google-gemini/gemini-cli/pull/11318) by [@allenhutchison](https://github.com/allenhutchison)) ## Announcements: v0.10.0 - 2025-10-13 - **Polish:** The team has been heads down bug fixing and investing heavily into polishing existing flows, tools, and interactions. - **Interactive Shell Tool calling:** Gemini CLI can now also execute interactive tools if needed ([pr](https://github.com/google-gemini/gemini-cli/pull/11225) by [@galz10](https://github.com/galz10)). - **Alt+Key support:** Enables broader support for Alt+Key keyboard shortcuts across different terminals. ([pr](https://github.com/google-gemini/gemini-cli/pull/10767) by [@srivatsj](https://github.com/srivatsj)). - **Telemetry Diff stats:** Track line changes made by the model and user during file operations via OTEL. ([pr](https://github.com/google-gemini/gemini-cli/pull/10819) by [@jerop](https://github.com/jerop)). ## Announcements: v0.9.0 - 2025-10-06 - 🎉 **Interactive Shell:** Run interactive commands like `vim`, `rebase -i`, or even `gemini` 😎 directly in Gemini CLI: - Blog: [https://developers.googleblog.com/en/say-hello-to-a-new-level-of-interactivity-in-gemini-cli/](https://developers.googleblog.com/en/say-hello-to-a-new-level-of-interactivity-in-gemini-cli/) - **Install pre-release extensions:** Install the latest `--pre-release` versions of extensions. Used for when an extension’s release hasn’t been marked as "latest". ([pr](https://github.com/google-gemini/gemini-cli/pull/10752) by [@jakemac53](https://github.com/jakemac53)) - **Simplified extension creation:** Create a new, empty extension. Templates are no longer required. ([pr](https://github.com/google-gemini/gemini-cli/pull/10629) by [@chrstnb](https://github.com/chrstnb)) - **OpenTelemetry GenAI metrics:** Aligns telemetry with industry-standard semantic conventions for improved interoperability. ([spec](https://opentelemetry.io/docs/concepts/semantic-conventions/), [pr](https://github.com/google-gemini/gemini-cli/pull/10343) by [@jerop](https://github.com/jerop)) - **List memory files:** Quickly find the location of your long-term memory files with `/memory list`. ([pr](https://github.com/google-gemini/gemini-cli/pull/10108) by [@sgnagnarella](https://github.com/sgnagnarella)) ## Announcements: v0.8.0 - 2025-09-29 - 🎉 **Announcing Gemini CLI Extensions** 🎉 - Completely customize your Gemini CLI experience to fit your workflow. - Build and share your own Gemini CLI extensions with the world. - Launching with a growing catalog of community, partner, and Google-built extensions. - Check extensions from [key launch partners](https://github.com/google-gemini/gemini-cli/discussions/10718). - Easy install: - `gemini extensions install ` - Easy management: - `gemini extensions install|uninstall|link` - `gemini extensions enable|disable` - `gemini extensions list|update|new` - Or use commands while running with `/extensions list|update`. - Everything you need to know: [Now open for building: Introducing Gemini CLI extensions](https://blog.google/technology/developers/gemini-cli-extensions/). - 🎉 **Our New Home Page & Better Documentation** 🎉 - Check out our new home page for better getting started material, reference documentation, extensions and more! - _Homepage:_ [https://geminicli.com](https://geminicli.com) - ‼️*NEW documentation:* [https://geminicli.com/docs](https://geminicli.com/docs) (Have any [suggestions](https://github.com/google-gemini/gemini-cli/discussions/8722)?) - _Extensions:_ [https://geminicli.com/extensions](https://geminicli.com/extensions) - **Non-Interactive Allowed Tools:** `--allowed-tools` will now also work in non-interactive mode. ([pr](https://github.com/google-gemini/gemini-cli/pull/9114) by [@mistergarrison](https://github.com/mistergarrison)) - **Terminal Title Status:** See the CLI's real-time status and thoughts directly in the terminal window's title by setting `showStatusInTitle: true`. ([pr](https://github.com/google-gemini/gemini-cli/pull/4386) by [@Fridayxiao](https://github.com/Fridayxiao)) - **Small features, polish, reliability & bug fixes:** A large amount of changes, smaller features, UI updates, reliability and bug fixes + general polish made it in this week! ## Announcements: v0.7.0 - 2025-09-22 - 🎉**Build your own Gemini CLI IDE plugin:** We've published a spec for creating IDE plugins to enable rich context-aware experiences and native in-editor diffing in your IDE of choice. ([pr](https://github.com/google-gemini/gemini-cli/pull/8479) by [@skeshive](https://github.com/skeshive)) - 🎉 **Gemini CLI extensions** - **Flutter:** An early version to help you create, build, test, and run Flutter apps with Gemini CLI ([extension](https://github.com/gemini-cli-extensions/flutter)) - **nanobanana:** Integrate nanobanana into Gemini CLI ([extension](https://github.com/gemini-cli-extensions/nanobanana)) - **Telemetry config via environment:** Manage telemetry settings using environment variables for a more flexible setup. ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/telemetry.md#configuration), [pr](https://github.com/google-gemini/gemini-cli/pull/9113) by [@jerop](https://github.com/jerop)) - **​​Experimental todos:** Track and display progress on complex tasks with a managed checklist. Off by default but can be enabled via `"useWriteTodos": true` ([pr](https://github.com/google-gemini/gemini-cli/pull/8761) by [@anj-s](https://github.com/anj-s)) - **Share chat support for tools:** Using `/chat share` will now also render function calls and responses in the final markdown file. ([pr](https://github.com/google-gemini/gemini-cli/pull/8693) by [@rramkumar1](https://github.com/rramkumar1)) - **Citations:** Now enabled for all users ([pr](https://github.com/google-gemini/gemini-cli/pull/8570) by [@scidomino](https://github.com/scidomino)) - **Custom commands in Headless Mode:** Run custom slash commands directly from the command line in non-interactive mode: `gemini "/joke Chuck Norris"` ([pr](https://github.com/google-gemini/gemini-cli/pull/8305) by [@capachino](https://github.com/capachino)) - **Small features, polish, reliability & bug fixes:** A large amount of changes, smaller features, UI updates, reliability and bug fixes + general polish made it in this week! ## Announcements: v0.6.0 - 2025-09-15 - 🎉 **Higher limits for Google AI Pro and Ultra subscribers:** We’re psyched to finally announce that Google AI Pro and AI Ultra subscribers now get access to significantly higher 2.5 quota limits for Gemini CLI! - **Announcement:** [https://blog.google/technology/developers/gemini-cli-code-assist-higher-limits/](https://blog.google/technology/developers/gemini-cli-code-assist-higher-limits/) - 🎉**Gemini CLI Databases and BigQuery Extensions:** Connect Gemini CLI to all of your cloud data with Gemini CLI. - Announcement and how to get started with each of the below extensions: [https://cloud.google.com/blog/products/databases/gemini-cli-extensions-for-google-data-cloud?e=48754805](https://cloud.google.com/blog/products/databases/gemini-cli-extensions-for-google-data-cloud?e=48754805) - **AlloyDB:** Interact, manage and observe AlloyDB for PostgreSQL databases ([manage](https://github.com/gemini-cli-extensions/alloydb#configuration), [observe](https://github.com/gemini-cli-extensions/alloydb-observability#configuration)) - **BigQuery:** Connect and query your BigQuery datasets or utilize a sub-agent for contextual insights ([query](https://github.com/gemini-cli-extensions/bigquery-data-analytics#configuration), [sub-agent](https://github.com/gemini-cli-extensions/bigquery-conversational-analytics)) - **Cloud SQL:** Interact, manage and observe Cloud SQL for PostgreSQL ([manage](https://github.com/gemini-cli-extensions/cloud-sql-postgresql#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-postgresql-observability#configuration)), Cloud SQL for MySQL ([manage](https://github.com/gemini-cli-extensions/cloud-sql-mysql#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-mysql-observability#configuration)) and Cloud SQL for SQL Server ([manage](https://github.com/gemini-cli-extensions/cloud-sql-sqlserver#configuration),[ observe](https://github.com/gemini-cli-extensions/cloud-sql-sqlserver-observability#configuration)) databases. - **Dataplex:** Discover, manage, and govern data and AI artifacts ([extension](https://github.com/gemini-cli-extensions/dataplex#configuration)) - **Firestore:** Interact with Firestore databases, collections and documents ([extension](https://github.com/gemini-cli-extensions/firestore-native#configuration)) - **Looker:** Query data, run Looks and create dashboards ([extension](https://github.com/gemini-cli-extensions/looker#configuration)) - **MySQL:** Interact with MySQL databases ([extension](https://github.com/gemini-cli-extensions/mysql#configuration)) - **Postgres:** Interact with PostgreSQL databases ([extension](https://github.com/gemini-cli-extensions/postgres#configuration)) - **Spanner:** Interact with Spanner databases ([extension](https://github.com/gemini-cli-extensions/spanner#configuration)) - **SQL Server:** Interact with SQL Server databases ([extension](https://github.com/gemini-cli-extensions/sql-server#configuration)) - **MCP Toolbox:** Configure and load custom tools for more than 30+ data sources ([extension](https://github.com/gemini-cli-extensions/mcp-toolbox#configuration)) - **JSON output mode:** Have Gemini CLI output JSON with `--output-format json` when invoked headlessly for easy parsing and post-processing. Includes response, stats and errors. ([pr](https://github.com/google-gemini/gemini-cli/pull/8119) by [@jerop](https://github.com/jerop)) - **Keybinding triggered approvals:** When you use shortcuts (`shift+y` or `shift+tab`) to activate YOLO/auto-edit modes any pending confirmation dialogs will now approve. ([pr](https://github.com/google-gemini/gemini-cli/pull/6665) by [@bulkypanda](https://github.com/bulkypanda)) - **Chat sharing:** Convert the current conversation to a Markdown or JSON file with _/chat share <file.md|file.json>_ ([pr](https://github.com/google-gemini/gemini-cli/pull/8139) by [@rramkumar1](https://github.com/rramkumar1)) - **Prompt search:** Search your prompt history using `ctrl+r`. ([pr](https://github.com/google-gemini/gemini-cli/pull/5539) by [@Aisha630](https://github.com/Aisha630)) - **Input undo/redo:** Recover accidentally deleted text in the input prompt using `ctrl+z` (undo) and `ctrl+shift+z` (redo). ([pr](https://github.com/google-gemini/gemini-cli/pull/4625) by [@masiafrest](https://github.com/masiafrest)) - **Loop detection confirmation:** When loops are detected you are now presented with a dialog to disable detection for the current session. ([pr](https://github.com/google-gemini/gemini-cli/pull/8231) by [@SandyTao520](https://github.com/SandyTao520)) - **Direct to Google Cloud Telemetry:** Directly send telemetry to Google Cloud for a simpler and more streamlined setup. ([pr](https://github.com/google-gemini/gemini-cli/pull/8541) by [@jerop](https://github.com/jerop)) - **Visual Mode Indicator Revamp:** ‘shell’, 'accept edits' and 'yolo' modes now have colors to match their impact / usage. Input box now also updates. ([shell](https://imgur.com/a/DovpVF1), [accept-edits](https://imgur.com/a/33KDz3J), [yolo](https://imgur.com/a/tbFwIWp), [pr](https://github.com/google-gemini/gemini-cli/pull/8200) by [@miguelsolorio](https://github.com/miguelsolorio)) - **Small features, polish, reliability & bug fixes:** A large amount of changes, smaller features, UI updates, reliability and bug fixes + general polish made it in this week! ## Announcements: v0.5.0 - 2025-09-08 - 🎉**FastMCP + Gemini CLI**🎉: Quickly install and manage your Gemini CLI MCP servers with FastMCP ([video](https://imgur.com/a/m8QdCPh), [pr](https://github.com/jlowin/fastmcp/pull/1709) by [@jackwotherspoon](https://github.com/jackwotherspoon)**)** - Getting started: [https://gofastmcp.com/integrations/gemini-cli](https://gofastmcp.com/integrations/gemini-cli) - **Positional Prompt for Non-Interactive:** Seamlessly invoke Gemini CLI headlessly via `gemini "Hello"`. Synonymous with passing `-p`. ([gif](https://imgur.com/a/hcBznpB), [pr](https://github.com/google-gemini/gemini-cli/pull/7668) by [@allenhutchison](https://github.com/allenhutchison)) - **Experimental Tool output truncation:** Enable truncating shell tool outputs and saving full output to a file by setting `"enableToolOutputTruncation": true `([pr](https://github.com/google-gemini/gemini-cli/pull/8039) by [@SandyTao520](https://github.com/SandyTao520)) - **Edit Tool improvements:** Gemini CLI’s ability to edit files should now be far more capable. ([pr](https://github.com/google-gemini/gemini-cli/pull/7679) by [@silviojr](https://github.com/silviojr)) - **Custom witty messages:** The feature you’ve all been waiting for… Personalized witty loading messages via `"ui": { "customWittyPhrases": ["YOLO"]}` in `settings.json`. ([pr](https://github.com/google-gemini/gemini-cli/pull/7641) by [@JayadityaGit](https://github.com/JayadityaGit)) - **Nested .gitignore File Handling:** Nested `.gitignore` files are now respected. ([pr](https://github.com/google-gemini/gemini-cli/pull/7645) by [@gsquared94](https://github.com/gsquared94)) - **Enforced authentication:** System administrators can now mandate a specific authentication method via `"enforcedAuthType": "oauth-personal|gemini-api-key|…"`in `settings.json`. ([pr](https://github.com/google-gemini/gemini-cli/pull/6564) by [@chrstnb](https://github.com/chrstnb)) - **A2A development-tool extension:** An RFC for an Agent2Agent ([A2A](https://a2a-protocol.org/latest/)) powered extension for developer tool use cases. ([feedback](https://github.com/google-gemini/gemini-cli/discussions/7822), [pr](https://github.com/google-gemini/gemini-cli/pull/7817) by [@skeshive](https://github.com/skeshive)) - **Hands on Codelab: **[https://codelabs.developers.google.com/gemini-cli-hands-on](https://codelabs.developers.google.com/gemini-cli-hands-on) - **Small features, polish, reliability & bug fixes:** A large amount of changes, smaller features, UI updates, reliability and bug fixes + general polish made it in this week! ## Announcements: v0.4.0 - 2025-09-01 - 🎉**Gemini CLI CloudRun and Security Integrations**🎉: Automate app deployment and security analysis with CloudRun and Security extension integrations. Once installed deploy your app to the cloud with `/deploy` and find and fix security vulnerabilities with `/security:analyze`. - Announcement and how to get started: [https://cloud.google.com/blog/products/ai-machine-learning/automate-app-deployment-and-security-analysis-with-new-gemini-cli-extensions](https://cloud.google.com/blog/products/ai-machine-learning/automate-app-deployment-and-security-analysis-with-new-gemini-cli-extensions) - **Experimental** - **Edit Tool:** Give our new edit tool a try by setting `"useSmartEdit": true` in `settings.json`! ([feedback](https://github.com/google-gemini/gemini-cli/discussions/7758), [pr](https://github.com/google-gemini/gemini-cli/pull/6823) by [@silviojr](https://github.com/silviojr)) - **Model talking to itself fix:** We’ve removed a model workaround that would encourage Gemini CLI to continue conversations on your behalf. This may be disruptive and can be disabled via `"skipNextSpeakerCheck": false` in your `settings.json` ([feedback](https://github.com/google-gemini/gemini-cli/discussions/6666), [pr](https://github.com/google-gemini/gemini-cli/pull/7614) by [@SandyTao520](https://github.com/SandyTao520)) - **Prompt completion:** Get real-time AI suggestions to complete your prompts as you type. Enable it with `"general": { "enablePromptCompletion": true }` and share your feedback! ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*hvegW7YXOg6N_beUWhTdxA.gif), [pr](https://github.com/google-gemini/gemini-cli/pull/4691) by [@3ks](https://github.com/3ks)) - **Footer visibility configuration:** Customize the CLI's footer look and feel in `settings.json` ([pr](https://github.com/google-gemini/gemini-cli/pull/7419) by [@miguelsolorio](https://github.com/miguelsolorio)) - `hideCWD`: hide current working directory. - `hideSandboxStatus`: hide sandbox status. - `hideModelInfo`: hide current model information. - `hideContextSummary`: hide request context summary. - **Citations:** For enterprise Code Assist licenses users will now see citations in their responses by default. Enable this yourself with `"showCitations": true` ([pr](https://github.com/google-gemini/gemini-cli/pull/7350) by [@scidomino](https://github.com/scidomino)) - **Pro Quota Dialog:** Handle daily Pro model usage limits with an interactive dialog that lets you immediately switch auth or fallback. ([pr](https://github.com/google-gemini/gemini-cli/pull/7094) by [@JayadityaGit](https://github.com/JayadityaGit)) - **Custom commands @:** Embed local file or directory content directly into your custom command prompts using `@{path}` syntax ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*GosBAo2SjMfFffAnzT7ZMg.gif), [pr](https://github.com/google-gemini/gemini-cli/pull/6716) by [@abhipatel12](https://github.com/abhipatel12)) - **2.5 Flash Lite support:** You can now use the `gemini-2.5-flash-lite` model for Gemini CLI via `gemini -m …`. ([gif](https://miro.medium.com/v2/resize:fit:2000/format:webp/1*P4SKwnrsyBuULoHrFqsFKQ.gif), [pr](https://github.com/google-gemini/gemini-cli/pull/4652) by [@psinha40898](https://github.com/psinha40898)) - **CLI streamlining:** We have deprecated a number of command line arguments in favor of `settings.json` alternatives. We will remove these arguments in a future release. See the PR for the full list of deprecations. ([pr](https://github.com/google-gemini/gemini-cli/pull/7360) by [@allenhutchison](https://github.com/allenhutchison)) - **JSON session summary:** Track and save detailed CLI session statistics to a JSON file for performance analysis with `--session-summary ` ([pr](https://github.com/google-gemini/gemini-cli/pull/7347) by [@leehagoodjames](https://github.com/leehagoodjames)) - **Robust keyboard handling:** More reliable and consistent behavior for arrow keys, special keys (Home, End, etc.), and modifier combinations across various terminals. ([pr](https://github.com/google-gemini/gemini-cli/pull/7118) by [@deepankarsharma](https://github.com/deepankarsharma)) - **MCP loading indicator:** Provides visual feedback during CLI initialization when connecting to multiple servers. ([pr](https://github.com/google-gemini/gemini-cli/pull/6923) by [@swissspidy](https://github.com/swissspidy)) - **Small features, polish, reliability & bug fixes:** A large amount of changes, smaller features, UI updates, reliability and bug fixes + general polish made it in this week! ================================================ FILE: docs/changelogs/latest.md ================================================ # Latest stable release: v0.34.0 Released: March 17, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: ``` npm install -g @google/gemini-cli ``` ## Highlights - **Plan Mode Enabled by Default**: The comprehensive planning capability is now enabled by default, allowing for better structured task management and execution. - **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) sandboxing as well as experimental LXC container sandboxing to provide more robust and isolated execution environments. - **Improved Loop Detection & Recovery**: Implemented iterative loop detection and model feedback mechanisms to prevent the CLI from getting stuck in repetitive actions. - **Customizable UI Elements**: You can now configure a custom footer using the new `/footer` command, and enjoy standardized semantic focus colors for better history visibility. - **Extensive Subagent Updates**: Refinements across the tracker visualization tools, background process logging, and broader fallback support for models in tool execution scenarios. ## What's Changed - feat(cli): add chat resume footer on session quit by @lordshashank in [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) - Support bold and other styles in svg snapshots by @jacob314 in [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) - fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) - Cleanup old branches. by @jacob314 in [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) - chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by @gemini-cli-robot in [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) - feat(ui): standardize semantic focus colors and enhance history visibility by @keithguerin in [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) - fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) - Add extra safety checks for proto pollution by @jacob314 in [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) - feat(core): Add tracker CRUD tools & visualization by @anj-s in [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) - Revert "fix(ui): persist expansion in AskUser dialog when navigating options" by @jacob314 in [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) - Changelog for v0.33.0-preview.0 by @gemini-cli-robot in [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) - fix: model persistence for all scenarios by @sripasg in [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) - chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by @gemini-cli-robot in [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) - Consistently guard restarts against concurrent auto updates by @scidomino in [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) - Defensive coding to reduce the risk of Maximum update depth errors by @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) - fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. by @jacob314 in [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) - Docs: Update plan mode docs by @jkcinouye in [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) - fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) - fix(cli): register extension lifecycle events in DebugProfiler by @fayerman-source in [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) - chore(dev): update vscode settings for typescriptreact by @rohit-4321 in [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) - fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) - Changelog for v0.32.0 by @gemini-cli-robot in [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) - Changelog for v0.33.0-preview.1 by @gemini-cli-robot in [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) - feat(core): improve @scripts/copy_files.js autocomplete to prioritize filenames by @sehoon38 in [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) - feat(sandbox): add experimental LXC container sandbox support by @h30s in [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) - feat(evals): add overall pass rate row to eval nightly summary table by @gundermanc in [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) - feat(telemetry): include language in telemetry and fix accepted lines computation by @gundermanc in [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) - Changelog for v0.32.1 by @gemini-cli-robot in [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) - feat(core): add robustness tests, logging, and metrics for CodeAssistServer SSE parsing by @yunaseoul in [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) - feat: add issue assignee workflow by @kartikangiras in [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) - fix: improve error message when OAuth succeeds but project ID is required by @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) - feat(loop-reduction): implement iterative loop detection and model feedback by @aishaneeshah in [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) - chore(github): require prompt approvers for agent prompt files by @gundermanc in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) - Docs: Create tools reference by @jkcinouye in [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) - fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions by @spencer426 in [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) - chore(cli): enable deprecated settings removal by default by @yashodipmore in [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) - feat(core): Disable fast ack helper for hints. by @joshualitt in [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) - fix(ui): suppress redundant failure note when tool error note is shown by @NTaylorMullen in [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) - docs: document planning workflows with Conductor example by @jerop in [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) - feat(release): ship esbuild bundle in npm package by @genneth in [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) - fix(extensions): preserve symlinks in extension source path while enforcing folder trust by @galz10 in [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) - fix(cli): defer tool exclusions to policy engine in non-interactive mode by @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) - fix(ui): removed double padding on rendered content by @devr0306 in [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) - fix(core): truncate excessively long lines in grep search output by @gundermanc in [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) - feat: add custom footer configuration via `/footer` by @jackwotherspoon in [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) - perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) - refactor(cli): categorize built-in themes into dark/ and light/ directories by @JayadityaGit in [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) - fix(core): explicitly allow codebase_investigator and cli_help in read-only mode by @Adib234 in [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) - test: add browser agent integration tests by @kunal-10-cloud in [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) - fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) - refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by @SandyTao520 in [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) - fix(ui): add partial output to cancelled shell UI by @devr0306 in [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) - fix(cli): replace hardcoded keybinding strings with dynamic formatters by @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) - DOCS: Update quota and pricing page by @g-samroberts in [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) - feat(telemetry): implement Clearcut logging for startup statistics by @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) - feat(triage): add area/documentation to issue triage by @g-samroberts in [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) - Fix so shell calls are formatted by @jacob314 in [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) - feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) - docs: use absolute paths for internal links in plan-mode.md by @jerop in [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) - fix(core): prevent unhandled AbortError crash during stream loop detection by @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) - fix:reorder env var redaction checks to scan values first by @kartikangiras in [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) - fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences by @skeshive in [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) - feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) - test(core): improve testing for API request/response parsing by @sehoon38 in [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) - docs(links): update docs-writer skill and fix broken link by @g-samroberts in [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) - Fix code colorizer ansi escape bug. by @jacob314 in [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) - remove wildcard behavior on keybindings by @scidomino in [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) - feat(acp): Add support for AI Gateway auth by @skeshive in [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) - fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) - feat (core): Implement tracker related SI changes by @anj-s in [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) - Changelog for v0.33.0-preview.2 by @gemini-cli-robot in [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) - Changelog for v0.33.0-preview.3 by @gemini-cli-robot in [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) - docs: format release times as HH:MM UTC by @pavan-sh in [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) - fix(cli): implement --all flag for extensions uninstall by @sehoon38 in [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) - docs: fix incorrect relative links to command reference by @kanywst in [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) - documentiong ensures ripgrep by @Jatin24062005 in [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) - fix(core): handle AbortError thrown during processTurn by @MumuTW in [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) - docs(cli): clarify ! command output visibility in shell commands tutorial by @MohammedADev in [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) - fix: logic for task tracker strategy and remove tracker tools by @anj-s in [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) - fix(partUtils): display media type and size for inline data parts by @Aboudjem in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) - Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) - fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) - fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive filesystems (#19904) by @Nixxx19 in [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) - feat(core): add concurrency safety guidance for subagent delegation (#17753) by @abhipatel12 in [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) - feat(ui): dynamically generate all keybinding hints by @scidomino in [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) - feat(core): implement unified KeychainService and migrate token storage by @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) - fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) - fix(plan): keep approved plan during chat compression by @ruomengz in [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) - feat(core): implement generic CacheService and optimize setupUser by @sehoon38 in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) - Update quota and pricing documentation with subscription tiers by @srithreepo in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) - fix(core): append correct OTLP paths for HTTP exporters by @sebastien-prudhomme in [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) - Changelog for v0.33.0-preview.4 by @gemini-cli-robot in [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) - feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) - refactor(core): standardize MCP tool naming to mcp\_ FQN format by @abhipatel12 in [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) - feat(cli): hide gemma settings from display and mark as experimental by @abhipatel12 in [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) - feat(skills): refine string-reviewer guidelines and description by @clocky in [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) - fix(core): whitelist TERM and COLORTERM in environment sanitization by @deadsmash07 in [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) - fix(billing): fix overage strategy lifecycle and settings integration by @gsquared94 in [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) - fix: expand paste placeholders in TextInput on submit by @Jefftree in [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) - fix(core): add in-memory cache to ChatRecordingService to prevent OOM by @SandyTao520 in [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) - feat(cli): overhaul thinking UI by @keithguerin in [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) - fix(ui): unify Ctrl+O expansion hint experience across buffer modes by @jwhelangoog in [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) - fix(cli): correct shell height reporting by @jacob314 in [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) - Make test suite pass when the GEMINI_SYSTEM_MD env variable or GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) - Disallow underspecified types by @gundermanc in [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) - refactor(cli): standardize on 'reload' verb for all components by @keithguerin in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) - feat(cli): Invert quota language to 'percent used' by @keithguerin in [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) - Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) - Code review comments as a pr by @jacob314 in [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) - feat(cli): unify /chat and /resume command UX by @LyalinDotCom in [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) - docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by @Gyanranjan-Priyam in [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) - fix(core): display actual graph output in tracker_visualize tool by @anj-s in [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) - fix(core): sanitize SSE-corrupted JSON and domain strings in error classification by @gsquared94 in [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) - Docs: Make documentation links relative by @diodesign in [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) - feat(cli): expose /tools desc as explicit subcommand for discoverability by @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) - feat(cli): add /compact alias for /compress command by @jackwotherspoon in [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) - feat(plan): enable Plan Mode by default by @jerop in [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) - feat(core): Introduce `AgentLoopContext`. by @joshualitt in [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) - fix(core): resolve symlinks for non-existent paths during validation by @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) - docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) - perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) - feat(cli): implement /upgrade command by @sehoon38 in [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) - Feat/browser agent progress emission by @kunal-10-cloud in [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) - fix(settings): display objects as JSON instead of [object Object] by @Zheyuan-Lin in [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) - Unmarshall update by @DavidAPierce in [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) - Update mcp's list function to check for disablement. by @DavidAPierce in [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) - robustness(core): static checks to validate history is immutable by @jacob314 in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) - refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) - feat(security): implement robust IP validation and safeFetch foundation by @alisa-alisa in [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) - feat(core): improve subagent result display by @joshualitt in [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) - docs: fix broken markdown syntax and anchor links in /tools by @campox747 in [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) - feat(policy): support subagent-specific policies in TOML by @akh64bit in [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) - Add script to speed up reviewing PRs adding a worktree. by @jacob314 in [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) - fix(core): prevent infinite recursion in symlink resolution by @Adib234 in [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) - fix(docs): fix headless mode docs by @ame2en in [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) - feat/redesign header compact by @jacob314 in [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) - refactor: migrate to useKeyMatchers hook by @scidomino in [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) - perf(cli): cache loadSettings to reduce redundant disk I/O at startup by @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) - fix(core): resolve Windows line ending and path separation bugs across CLI by @muhammadusman586 in [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) - docs: fix heading formatting in commands.md and phrasing in tools-api.md by @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) - refactor(ui): unify keybinding infrastructure and support string initialization by @scidomino in [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) - Add support for updating extension sources and names by @chrstnb in [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) - fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) - fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) - fix(docs): update theme screenshots and add missing themes by @ashmod in [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) - refactor(cli): rename 'return' key to 'enter' internally by @scidomino in [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) - build(release): restrict npm bundling to non-stable tags by @sehoon38 in [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) - fix(core): override toolRegistry property for sub-agent schedulers by @gsquared94 in [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) - fix(cli): make footer items equally spaced by @jacob314 in [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) - docs: clarify global policy rules application in plan mode by @jerop in [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) - fix(core): ensure correct flash model steering in plan mode implementation phase by @jerop in [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) - fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) - refactor(core): improve API response error logging when retry by @yunaseoul in [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) - fix(ui): handle headless execution in credits and upgrade dialogs by @gsquared94 in [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) - fix(core): treat retryable errors with >5 min delay as terminal quota errors by @gsquared94 in [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) - feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub Actions by @cocosheng-g in [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) - feat(core): add OAuth2 Authorization Code auth provider for A2A agents by @SandyTao520 in [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) - feat(cli): give visibility to /tools list command in the TUI and follow the subcommand pattern of other commands by @JayadityaGit in [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) - Handle dirty worktrees better and warn about running scripts/review.sh on untrusted code. by @jacob314 in [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) - feat(policy): support auto-add to policy by default and scoped persistence by @spencer426 in [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) - fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) - fix(release): Improve Patch Release Workflow Comments: Clearer Approval Guidance by @jerop in [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) - docs: clarify telemetry setup and comprehensive data map by @jerop in [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) - feat(core): add per-model token usage to stream-json output by @yongruilin in [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) - docs: remove experimental badge from plan mode in sidebar by @jerop in [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) - fix(cli): prevent race condition in loop detection retry by @skyvanguard in [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) - Add behavioral evals for tracker by @anj-s in [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) - fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) - docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) - fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) - make command names consistent by @scidomino in [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) - refactor: remove agent_card_requires_auth config flag by @adamfweidman in [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) - feat(a2a): implement standardized normalization and streaming reassembly by @alisa-alisa in [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) - feat(cli): enable skill activation via slash commands by @NTaylorMullen in [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) - docs(cli): mention per-model token usage in stream-json result event by @yongruilin in [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) - fix(plan): prevent plan truncation in approval dialog by supporting unconstrained heights by @Adib234 in [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) - feat(a2a): switch from callback-based to event-driven tool scheduler by @cocosheng-g in [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) - feat(voice): implement speech-friendly response formatter by @ayush31010 in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) - feat: add pulsating blue border automation overlay to browser agent by @kunal-10-cloud in [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) - Add extensionRegistryURI setting to change where the registry is read from by @kevinjwang1 in [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) - fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) - fix: prevent hangs in non-interactive mode and improve agent guidance by @cocosheng-g in [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) - Add ExtensionDetails dialog and support install by @chrstnb in [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) - chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by @gemini-cli-robot in [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) - Changelog for v0.33.0-preview.13 by @gemini-cli-robot in [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) - fix(cli): stabilize prompt layout to prevent jumping when typing by @NTaylorMullen in [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) - fix: preserve prompt text when cancelling streaming by @Nixxx19 in [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) - fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) - feat: implement background process logging and cleanup by @galz10 in [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) - Changelog for v0.33.0-preview.14 by @gemini-cli-robot in [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) - fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] by @gemini-cli-robot in [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) - fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch version v0.34.0-preview.1 and create version 0.34.0-preview.2 by @gemini-cli-robot in [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) - fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch version v0.34.0-preview.2 and create version 0.34.0-preview.3 by @gemini-cli-robot in [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) - fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch version v0.34.0-preview.3 and create version 0.34.0-preview.4 by @gemini-cli-robot in [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) **Full Changelog**: https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 ================================================ FILE: docs/changelogs/preview.md ================================================ # Preview release: v0.35.0-preview.2 Released: March 19, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). To install the preview release: ``` npm install -g @google/gemini-cli@preview ``` ## Highlights - **Subagents & Architecture Enhancements**: Enabled subagents and laid the foundation for subagent tool isolation. Added proxy routing support for remote A2A subagents and integrated `SandboxManager` to sandbox all process-spawning tools. - **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and support for literal character keybindings. Added missing vim mode motions and CJK input support. Enabled code splitting and deferred UI loading for improved performance. - **Context & Tools Optimization**: JIT context loading is now enabled by default with deduplication for project memory. Introduced a model-driven parallel tool scheduler and allowed safe tools to execute concurrently. - **Security & Extensions**: Implemented cryptographic integrity verification for extension updates and added a `disableAlwaysAllow` setting to prevent auto-approvals for enhanced security. - **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 security and consistency improvements for the `web_fetch` tool. ## What's Changed - fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch version v0.35.0-preview.1 and create version 0.35.0-preview.2 by @gemini-cli-robot in [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) - feat(cli): customizable keyboard shortcuts by @scidomino in [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) - feat(core): Thread `AgentLoopContext` through core. by @joshualitt in [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) - chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by @gemini-cli-robot in [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) - refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) - feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) - Feat/retry fetch notifications by @aishaneeshah in [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) - fix(core): remove OAuth check from handleFallback and clean up stray file by @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) - feat(cli): support literal character keybindings and extended Kitty protocol keys by @scidomino in [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) - fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) - test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) - fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) - docs(cli): add custom keybinding documentation by @scidomino in [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) - docs: fix misleading YOLO mode description in defaultApprovalMode by @Gyanranjan-Priyam in [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) - fix: clean up /clear and /resume by @jackwotherspoon in [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) - fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) - fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) - feat(cli): support removing keybindings via '-' prefix by @scidomino in [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) - feat(policy): add --admin-policy flag for supplemental admin policies by @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) - merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) - perf(core): parallelize user quota and experiments fetching in refreshAuth by @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) - Changelog for v0.34.0-preview.0 by @gemini-cli-robot in [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) - Changelog for v0.33.0 by @gemini-cli-robot in [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) - fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) - feat(core): include initiationMethod in conversation interaction telemetry by @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) - feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) - fix(core): enable numerical routing for api key users by @sehoon38 in [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) - feat(telemetry): implement retry attempt telemetry for network related retries by @aishaneeshah in [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) - fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) - fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) - chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) - feat(core): support custom base URL via env vars by @junaiddshaukat in [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) - merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) - fix(core): silently retry API errors up to 3 times before halting session by @spencer426 in [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) - feat(core): simplify subagent success UI and improve early termination display by @abhipatel12 in [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) - merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) - fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) - feat(core): implement SandboxManager interface and config schema by @galz10 in [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) - docs: document npm deprecation warnings as safe to ignore by @h30s in [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) - fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) - fix(core): propagate subagent context to policy engine by @NTaylorMullen in [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) - fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) - docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) - fix(policy): ensure user policies are loaded when policyPaths is empty by @NTaylorMullen in [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) - Docs: Add documentation for model steering (experimental). by @jkcinouye in [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) - Add issue for automated changelogs by @g-samroberts in [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) - fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by @spencer426 in [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) - feat(core): differentiate User-Agent for a2a-server and ACP clients by @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) - refactor(core): extract ExecutionLifecycleService for tool backgrounding by @adamfweidman in [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) - feat: Display pending and confirming tool calls by @sripasg in [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) - feat(browser): implement input blocker overlay during automation by @kunal-10-cloud in [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) - fix: register themes on extension load not start by @jackwotherspoon in [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) - feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) - chore: remove unnecessary log for themes by @jackwotherspoon in [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) - fix(core): resolve MCP tool FQN validation, schema export, and wildcards in subagents by @abhipatel12 in [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) - fix(cli): validate --model argument at startup by @JaisalJain in [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) - fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) - feat(telemetry): add Clearcut instrumentation for AI credits billing events by @gsquared94 in [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) - feat(core): add google credentials provider for remote agents by @adamfweidman in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) - test(cli): add integration test for node deprecation warnings by @Nixxx19 in [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) - feat(cli): allow safe tools to execute concurrently while agent is busy by @spencer426 in [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) - feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) - update vulnerable deps by @scidomino in [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) - fix(core): fix startup stats to use int values for timestamps and durations by @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) - fix(core): prevent duplicate tool schemas for instantiated tools by @abhipatel12 in [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) - fix(core): add proxy routing support for remote A2A subagents by @adamfweidman in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) - fix(core/ide): add Antigravity CLI fallbacks by @apfine in [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) - fix(browser): fix duplicate function declaration error in browser agent by @gsquared94 in [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) - feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) - Changelog for v0.34.0-preview.1 by @gemini-cli-robot in [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) - perf(cli): enable code splitting and deferred UI loading by @sehoon38 in [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) - fix: remove unused img.png from project root by @SandyTao520 in [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) - docs(local model routing): add docs on how to use Gemma for local model routing by @douglas-reid in [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) - feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) - fix(cli): escape @ symbols on paste to prevent unintended file expansion by @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) - feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) - docs: clarify that tools.core is an allowlist for ALL built-in tools by @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) - docs(plan): document hooks with plan mode by @ruomengz in [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) - Changelog for v0.33.1 by @gemini-cli-robot in [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) - build(ci): fix false positive evals trigger on merge commits by @gundermanc in [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) - fix(core): explicitly pass messageBus to policy engine for MCP tool saves by @abhipatel12 in [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) - feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) - feat(core): increase sub-agent turn and time limits by @bdmorgan in [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) - feat(core): instrument file system tools for JIT context discovery by @SandyTao520 in [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) - refactor(ui): extract pure session browser utilities by @abhipatel12 in [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) - fix(plan): Fix AskUser evals by @Adib234 in [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) - fix(settings): prevent j/k navigation keys from intercepting edit buffer input by @student-ankitpandit in [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) - feat(skills): improve async-pr-review workflow and logging by @mattKorwel in [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) - refactor(cli): consolidate getErrorMessage utility to core by @scidomino in [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) - fix(core): show descriptive error messages when saving settings fails by @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) - docs(core): add authentication guide for remote subagents by @adamfweidman in [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) - docs: overhaul subagents documentation and add /agents command by @abhipatel12 in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) - refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) - test: add Object.create context regression test and tool confirmation integration test by @gsquared94 in [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) - feat(tracker): return TodoList display for tracker tools by @anj-s in [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) - feat(agent): add allowed domain restrictions for browser agent by @cynthialong0-0 in [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) - chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by @gemini-cli-robot in [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) - Move keychain fallback to keychain service by @chrstnb in [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) - feat(core): integrate SandboxManager to sandbox all process-spawning tools by @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) - fix(cli): support CJK input and full Unicode scalar values in terminal protocols by @scidomino in [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) - Promote stable tests. by @gundermanc in [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) - feat(tracker): add tracker policy by @anj-s in [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) - feat(security): add disableAlwaysAllow setting to disable auto-approvals by @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) - Revert "fix(cli): validate --model argument at startup" by @sehoon38 in [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) - fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) - fix(core): use session-specific temp directory for task tracker by @anj-s in [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) - Fix issue where config was undefined. by @gundermanc in [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) - fix(core): deduplicate project memory when JIT context is enabled by @SandyTao520 in [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) - feat(prompts): implement Topic-Action-Summary model for verbosity reduction by @Abhijit-2592 in [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) - fix(core): fix manual deletion of subagent histories by @abhipatel12 in [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) - Add registry var by @kevinjwang1 in [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) - Add ModelDefinitions to ModelConfigService by @kevinjwang1 in [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) - fix(cli): improve command conflict handling for skills by @NTaylorMullen in [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) - fix(core): merge user settings with extension-provided MCP servers by @abhipatel12 in [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) - fix(core): skip discovery for incomplete MCP configs and resolve merge race condition by @abhipatel12 in [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) - fix(automation): harden stale PR closer permissions and maintainer detection by @bdmorgan in [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) - fix(automation): evaluate staleness before checking protected labels by @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) - feat(agent): replace the runtime npx for browser agent chrome devtool mcp with pre-built bundle by @cynthialong0-0 in [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) - perf: optimize TrackerService dependency checks by @anj-s in [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) - docs(policy): remove trailing space from commandPrefix examples by @kawasin73 in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) - fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) - fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled tool calls. by @sripasg in [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) - Disallow Object.create() and reflect. by @gundermanc in [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) - Guard pro model usage by @sehoon38 in [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) - refactor(core): Creates AgentSession abstraction for consolidated agent interface. by @mbleigh in [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) - docs(changelog): remove internal commands from release notes by @jackwotherspoon in [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) - feat: enable subagents by @abhipatel12 in [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) - feat(extensions): implement cryptographic integrity verification for extension updates by @ehedlund in [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) - feat(tracker): polish UI sorting and formatting by @anj-s in [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) - Changelog for v0.34.0-preview.2 by @gemini-cli-robot in [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) - fix(core): fix three JIT context bugs in read_file, read_many_files, and memoryDiscovery by @SandyTao520 in [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) - refactor(core): introduce InjectionService with source-aware injection and backend-native background completions by @adamfweidman in [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) - Linux sandbox bubblewrap by @DavidAPierce in [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) - feat(core): increase thought signature retry resilience by @bdmorgan in [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) - feat(core): implement Stage 2 security and consistency improvements for web_fetch by @aishaneeshah in [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) - refactor(core): replace positional execute params with ExecuteOptions bag by @adamfweidman in [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) - feat(config): enable JIT context loading by default by @SandyTao520 in [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) - fix(config): ensure discoveryMaxDirs is passed to global config during initialization by @kevin-ramdass in [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) - fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) - Changelog for v0.34.0-preview.3 by @gemini-cli-robot in [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) - feat(core): add foundation for subagent tool isolation by @akh64bit in [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) - fix(core): handle surrogate pairs in truncateString by @sehoon38 in [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) - fix(cli): override j/k navigation in settings dialog to fix search input conflict by @sehoon38 in [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) - feat(plan): add 'All the above' option to multi-select AskUser questions by @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) - docs: distribute package-specific GEMINI.md context to each package by @SandyTao520 in [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) - fix(cli): clean up stale pasted placeholder metadata after word/line deletions by @Jomak-x in [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) - refactor(core): align JIT memory placement with tiered context model by @SandyTao520 in [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) - Linux sandbox seccomp by @DavidAPierce in [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) **Full Changelog**: https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2 ================================================ FILE: docs/cli/checkpointing.md ================================================ # Checkpointing The Gemini CLI includes a Checkpointing feature that automatically saves a snapshot of your project's state before any file modifications are made by AI-powered tools. This lets you safely experiment with and apply code changes, knowing you can instantly revert back to the state before the tool was run. ## How it works When you approve a tool that modifies the file system (like `write_file` or `replace`), the CLI automatically creates a "checkpoint." This checkpoint includes: 1. **A Git snapshot:** A commit is made in a special, shadow Git repository located in your home directory (`~/.gemini/history/`). This snapshot captures the complete state of your project files at that moment. It does **not** interfere with your own project's Git repository. 2. **Conversation history:** The entire conversation you've had with the agent up to that point is saved. 3. **The tool call:** The specific tool call that was about to be executed is also stored. If you want to undo the change or simply go back, you can use the `/restore` command. Restoring a checkpoint will: - Revert all files in your project to the state captured in the snapshot. - Restore the conversation history in the CLI. - Re-propose the original tool call, allowing you to run it again, modify it, or simply ignore it. All checkpoint data, including the Git snapshot and conversation history, is stored locally on your machine. The Git snapshot is stored in the shadow repository while the conversation history and tool calls are saved in a JSON file in your project's temporary directory, typically located at `~/.gemini/tmp//checkpoints`. ## Enabling the feature The Checkpointing feature is disabled by default. To enable it, you need to edit your `settings.json` file. > [!CAUTION] > The `--checkpointing` command-line flag was removed in version > 0.11.0. Checkpointing can now only be enabled through the `settings.json` > configuration file. Add the following key to your `settings.json`: ```json { "general": { "checkpointing": { "enabled": true } } } ``` ## Using the `/restore` command Once enabled, checkpoints are created automatically. To manage them, you use the `/restore` command. ### List available checkpoints To see a list of all saved checkpoints for the current project, simply run: ``` /restore ``` The CLI will display a list of available checkpoint files. These file names are typically composed of a timestamp, the name of the file being modified, and the name of the tool that was about to be run (e.g., `2025-06-22T10-00-00_000Z-my-file.txt-write_file`). ### Restore a specific checkpoint To restore your project to a specific checkpoint, use the checkpoint file from the list: ``` /restore ``` For example: ``` /restore 2025-06-22T10-00-00_000Z-my-file.txt-write_file ``` After running the command, your files and conversation will be immediately restored to the state they were in when the checkpoint was created, and the original tool prompt will reappear. ================================================ FILE: docs/cli/cli-reference.md ================================================ # Gemini CLI cheatsheet This page provides a reference for commonly used Gemini CLI commands, options, and parameters. ## CLI commands | Command | Description | Example | | ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ | | `gemini` | Start interactive REPL | `gemini` | | `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` | | `gemini "query"` | Query and continue interactively | `gemini "explain this project"` | | `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` | | `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | | `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | | `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` | | `gemini -r "" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` | | `gemini update` | Update to latest version | `gemini update` | | `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | | `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | ### Positional arguments | Argument | Type | Description | | -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | | `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. | ## Interactive commands These commands are available within the interactive REPL. | Command | Description | | -------------------- | ---------------------------------------- | | `/skills reload` | Reload discovered skills from disk | | `/agents reload` | Reload the agent registry | | `/commands reload` | Reload custom slash commands | | `/memory reload` | Reload context files (e.g., `GEMINI.md`) | | `/mcp reload` | Restart and reload MCP servers | | `/extensions reload` | Reload all active extensions | | `/help` | Show help for all commands | | `/quit` | Exit the interactive session | ## CLI Options | Option | Alias | Type | Default | Description | | -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | | `--version` | `-v` | - | - | Show CLI version number and exit | | `--help` | `-h` | - | - | Show help information | | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | | `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | | `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | | `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | | `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | | `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | | `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | | `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | | `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | | `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | | `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | | `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | | `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | ## Model selection The `--model` (or `-m`) flag lets you specify which Gemini model to use. You can use either model aliases (user-friendly names) or concrete model names. ### Model aliases These are convenient shortcuts that map to specific models: | Alias | Resolves To | Description | | ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `auto` | `gemini-2.5-pro` or `gemini-3-pro-preview` | **Default.** Resolves to the preview model if preview features are enabled, otherwise resolves to the standard pro model. | | `pro` | `gemini-2.5-pro` or `gemini-3-pro-preview` | For complex reasoning tasks. Uses preview model if enabled. | | `flash` | `gemini-2.5-flash` | Fast, balanced model for most tasks. | | `flash-lite` | `gemini-2.5-flash-lite` | Fastest model for simple tasks. | ## Extensions management | Command | Description | Example | | -------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | | `gemini extensions install ` | Install extension from Git URL or local path | `gemini extensions install https://github.com/user/my-extension` | | `gemini extensions install --ref ` | Install from specific branch/tag/commit | `gemini extensions install https://github.com/user/my-extension --ref develop` | | `gemini extensions install --auto-update` | Install with auto-update enabled | `gemini extensions install https://github.com/user/my-extension --auto-update` | | `gemini extensions uninstall ` | Uninstall one or more extensions | `gemini extensions uninstall my-extension` | | `gemini extensions list` | List all installed extensions | `gemini extensions list` | | `gemini extensions update ` | Update a specific extension | `gemini extensions update my-extension` | | `gemini extensions update --all` | Update all extensions | `gemini extensions update --all` | | `gemini extensions enable ` | Enable an extension | `gemini extensions enable my-extension` | | `gemini extensions disable ` | Disable an extension | `gemini extensions disable my-extension` | | `gemini extensions link ` | Link local extension for development | `gemini extensions link /path/to/extension` | | `gemini extensions new ` | Create new extension from template | `gemini extensions new ./my-extension` | | `gemini extensions validate ` | Validate extension structure | `gemini extensions validate ./my-extension` | See [Extensions Documentation](../extensions/index.md) for more details. ## MCP server management | Command | Description | Example | | ------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | | `gemini mcp add ` | Add stdio-based MCP server | `gemini mcp add github npx -y @modelcontextprotocol/server-github` | | `gemini mcp add --transport http` | Add HTTP-based MCP server | `gemini mcp add api-server http://localhost:3000 --transport http` | | `gemini mcp add --env KEY=value` | Add with environment variables | `gemini mcp add slack node server.js --env SLACK_TOKEN=xoxb-xxx` | | `gemini mcp add --scope user` | Add with user scope | `gemini mcp add db node db-server.js --scope user` | | `gemini mcp add --include-tools tool1,tool2` | Add with specific tools | `gemini mcp add github npx -y @modelcontextprotocol/server-github --include-tools list_repos,get_pr` | | `gemini mcp remove ` | Remove an MCP server | `gemini mcp remove github` | | `gemini mcp list` | List all configured MCP servers | `gemini mcp list` | See [MCP Server Integration](../tools/mcp-server.md) for more details. ## Skills management | Command | Description | Example | | -------------------------------- | ------------------------------------- | ------------------------------------------------- | | `gemini skills list` | List all discovered agent skills | `gemini skills list` | | `gemini skills install ` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` | | `gemini skills link ` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` | | `gemini skills uninstall ` | Uninstall an agent skill | `gemini skills uninstall my-skill` | | `gemini skills enable ` | Enable an agent skill | `gemini skills enable my-skill` | | `gemini skills disable ` | Disable an agent skill | `gemini skills disable my-skill` | | `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` | | `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` | See [Agent Skills Documentation](./skills.md) for more details. ================================================ FILE: docs/cli/creating-skills.md ================================================ # Creating Agent Skills This guide provides an overview of how to create your own Agent Skills to extend the capabilities of Gemini CLI. ## Getting started: The `skill-creator` skill The recommended way to create a new skill is to use the built-in `skill-creator` skill. To use it, ask Gemini CLI to create a new skill for you. **Example prompt:** > "create a new skill called 'code-reviewer'" Gemini CLI will then use the `skill-creator` to generate the skill: 1. Generate a new directory for your skill (e.g., `my-new-skill/`). 2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and `description`). 3. Create the standard resource directories: `scripts/`, `references/`, and `assets/`. ## Manual skill creation If you prefer to create skills manually: 1. **Create a directory** for your skill (e.g., `my-new-skill/`). 2. **Create a `SKILL.md` file** inside the new directory. To add additional resources that support the skill, refer to the skill structure. ## Skill structure A skill is a directory containing a `SKILL.md` file at its root. ### Folder structure While a `SKILL.md` file is the only required component, we recommend the following structure for organizing your skill's resources: ```text my-skill/ ├── SKILL.md (Required) Instructions and metadata ├── scripts/ (Optional) Executable scripts ├── references/ (Optional) Static documentation └── assets/ (Optional) Templates and other resources ``` ### `SKILL.md` file The `SKILL.md` file is the core of your skill. This file uses YAML frontmatter for metadata and Markdown for instructions. For example: ```markdown --- name: code-reviewer description: Use this skill to review code. It supports both local changes and remote Pull Requests. --- # Code Reviewer This skill guides the agent in conducting thorough code reviews. ## Workflow ### 1. Determine Review Target - **Remote PR**: If the user gives a PR number or URL, target that remote PR. - **Local Changes**: If changes are local... ... ``` - **`name`**: A unique identifier for the skill. This should match the directory name. - **`description`**: A description of what the skill does and when Gemini should use it. - **Body**: The Markdown body of the file contains the instructions that guide the agent's behavior when the skill is active. ================================================ FILE: docs/cli/custom-commands.md ================================================ # Custom commands Custom commands let you save and reuse your favorite or most frequently used prompts as personal shortcuts within Gemini CLI. You can create commands that are specific to a single project or commands that are available globally across all your projects, streamlining your workflow and ensuring consistency. ## File locations and precedence Gemini CLI discovers commands from two locations, loaded in a specific order: 1. **User commands (global):** Located in `~/.gemini/commands/`. These commands are available in any project you are working on. 2. **Project commands (local):** Located in `/.gemini/commands/`. These commands are specific to the current project and can be checked into version control to be shared with your team. If a command in the project directory has the same name as a command in the user directory, the **project command will always be used.** This allows projects to override global commands with project-specific versions. ## Naming and namespacing The name of a command is determined by its file path relative to its `commands` directory. Subdirectories are used to create namespaced commands, with the path separator (`/` or `\`) being converted to a colon (`:`). - A file at `~/.gemini/commands/test.toml` becomes the command `/test`. - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. > [!TIP] > After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. ## TOML file format (v1) Your command definition files must be written in the TOML format and use the `.toml` file extension. ### Required fields - `prompt` (String): The prompt that will be sent to the Gemini model when the command is executed. This can be a single-line or multi-line string. ### Optional fields - `description` (String): A brief, one-line description of what the command does. This text will be displayed next to your command in the `/help` menu. **If you omit this field, a generic description will be generated from the filename.** ## Handling arguments Custom commands support two powerful methods for handling arguments. The CLI automatically chooses the correct method based on the content of your command's `prompt`. ### 1. Context-aware injection with `{{args}}` If your `prompt` contains the special placeholder `{{args}}`, the CLI will replace that placeholder with the text the user typed after the command name. The behavior of this injection depends on where it is used: **A. Raw injection (outside shell commands)** When used in the main body of the prompt, the arguments are injected exactly as the user typed them. **Example (`git/fix.toml`):** ```toml # Invoked via: /git:fix "Button is misaligned" description = "Generates a fix for a given issue." prompt = "Please provide a code fix for the issue described here: {{args}}." ``` The model receives: `Please provide a code fix for the issue described here: "Button is misaligned".` **B. Using arguments in shell commands (inside `!{...}` blocks)** When you use `{{args}}` inside a shell injection block (`!{...}`), the arguments are automatically **shell-escaped** before replacement. This allows you to safely pass arguments to shell commands, ensuring the resulting command is syntactically correct and secure while preventing command injection vulnerabilities. **Example (`/grep-code.toml`):** ```toml prompt = """ Please summarize the findings for the pattern `{{args}}`. Search Results: !{grep -r {{args}} .} """ ``` When you run `/grep-code It's complicated`: 1. The CLI sees `{{args}}` used both outside and inside `!{...}`. 2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. 3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on Linux: `"It\'s complicated"`). 4. The command executed is `grep -r "It's complicated" .`. 5. The CLI prompts you to confirm this exact, secure command before execution. 6. The final prompt is sent. ### 2. Default argument handling If your `prompt` does **not** contain the special placeholder `{{args}}`, the CLI uses a default behavior for handling arguments. If you provide arguments to the command (e.g., `/mycommand arg1`), the CLI will append the full command you typed to the end of the prompt, separated by two newlines. This allows the model to see both the original instructions and the specific arguments you just provided. If you do **not** provide any arguments (e.g., `/mycommand`), the prompt is sent to the model exactly as it is, with nothing appended. **Example (`changelog.toml`):** This example shows how to create a robust command by defining a role for the model, explaining where to find the user's input, and specifying the expected format and behavior. ```toml # In: /.gemini/commands/changelog.toml # Invoked via: /changelog 1.2.0 added "Support for default argument parsing." description = "Adds a new entry to the project's CHANGELOG.md file." prompt = """ # Task: Update Changelog You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog. **The user's raw command is appended below your instructions.** Your task is to parse the ``, ``, and `` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file. ## Expected Format The command follows this format: `/changelog ` - `` must be one of: "added", "changed", "fixed", "removed". ## Behavior 1. Read the `CHANGELOG.md` file. 2. Find the section for the specified ``. 3. Add the `` under the correct `` heading. 4. If the version or type section doesn't exist, create it. 5. Adhere strictly to the "Keep a Changelog" format. """ ``` When you run `/changelog 1.2.0 added "New feature"`, the final text sent to the model will be the original prompt followed by two newlines and the command you typed. ### 3. Executing shell commands with `!{...}` You can make your commands dynamic by executing shell commands directly within your `prompt` and injecting their output. This is ideal for gathering context from your local environment, like reading file content or checking the status of Git. When a custom command attempts to execute a shell command, Gemini CLI will now prompt you for confirmation before proceeding. This is a security measure to ensure that only intended commands can be run. **How it works:** 1. **Inject commands:** Use the `!{...}` syntax. 2. **Argument substitution:** If `{{args}}` is present inside the block, it is automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). 3. **Robust parsing:** The parser correctly handles complex shell commands that include nested braces, such as JSON payloads. The content inside `!{...}` must have balanced braces (`{` and `}`). If you need to execute a command containing unbalanced braces, consider wrapping it in an external script file and calling the script within the `!{...}` block. 4. **Security check and confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. 5. **Execution and error reporting:** The command is executed. If the command fails, the output injected into the prompt will include the error messages (stderr) followed by a status line, e.g., `[Shell command exited with code 1]`. This helps the model understand the context of the failure. **Example (`git/commit.toml`):** This command gets the staged git diff and uses it to ask the model to write a commit message. ````toml # In: /.gemini/commands/git/commit.toml # Invoked via: /git:commit description = "Generates a Git commit message based on staged changes." # The prompt uses !{...} to execute the command and inject its output. prompt = """ Please generate a Conventional Commit message based on the following git diff: ```diff !{git diff --staged} ``` """ ```` When you run `/git:commit`, the CLI first executes `git diff --staged`, then replaces `!{git diff --staged}` with the output of that command before sending the final, complete prompt to the model. ### 4. Injecting file content with `@{...}` You can directly embed the content of a file or a directory listing into your prompt using the `@{...}` syntax. This is useful for creating commands that operate on specific files. **How it works:** - **File injection**: `@{path/to/file.txt}` is replaced by the content of `file.txt`. - **Multimodal support**: If the path points to a supported image (e.g., PNG, JPEG), PDF, audio, or video file, it will be correctly encoded and injected as multimodal input. Other binary files are handled gracefully and skipped. - **Directory listing**: `@{path/to/dir}` is traversed and each file present within the directory and all subdirectories is inserted into the prompt. This respects `.gitignore` and `.geminiignore` if enabled. - **Workspace-aware**: The command searches for the path in the current directory and any other workspace directories. Absolute paths are allowed if they are within the workspace. - **Processing order**: File content injection with `@{...}` is processed _before_ shell commands (`!{...}`) and argument substitution (`{{args}}`). - **Parsing**: The parser requires the content inside `@{...}` (the path) to have balanced braces (`{` and `}`). **Example (`review.toml`):** This command injects the content of a _fixed_ best practices file (`docs/best-practices.md`) and uses the user's arguments to provide context for the review. ```toml # In: /.gemini/commands/review.toml # Invoked via: /review FileCommandLoader.ts description = "Reviews the provided context using a best practice guide." prompt = """ You are an expert code reviewer. Your task is to review {{args}}. Use the following best practices when providing your review: @{docs/best-practices.md} """ ``` When you run `/review FileCommandLoader.ts`, the `@{docs/best-practices.md}` placeholder is replaced by the content of that file, and `{{args}}` is replaced by the text you provided, before the final prompt is sent to the model. --- ## Example: A "Pure Function" refactoring command Let's create a global command that asks the model to refactor a piece of code. **1. Create the file and directories:** First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file. **macOS/Linux** ```bash mkdir -p ~/.gemini/commands/refactor touch ~/.gemini/commands/refactor/pure.toml ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\commands\refactor" New-Item -ItemType File -Force -Path "$env:USERPROFILE\.gemini\commands\refactor\pure.toml" ``` **2. Add the content to the file:** Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the following content. We are including the optional `description` for best practice. ```toml # In: ~/.gemini/commands/refactor/pure.toml # This command will be invoked via: /refactor:pure description = "Asks the model to refactor the current context into a pure function." prompt = """ Please analyze the code I've provided in the current context. Refactor it into a pure function. Your response should include: 1. The refactored, pure function code block. 2. A brief explanation of the key changes you made and why they contribute to purity. """ ``` **3. Run the command:** That's it! You can now run your command in the CLI. First, you might add a file to the context, and then invoke your command: ``` > @my-messy-function.js > /refactor:pure ``` Gemini CLI will then execute the multi-line prompt defined in your TOML file. ================================================ FILE: docs/cli/enterprise.md ================================================ # Gemini CLI for the enterprise This document outlines configuration patterns and best practices for deploying and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users. > [!WARNING] > The patterns described in this document are intended to help > administrators create a more controlled and secure environment for using > Gemini CLI. However, they should not be considered a foolproof security > boundary. A determined user with sufficient privileges on their local machine > may still be able to circumvent these configurations. These measures are > designed to prevent accidental misuse and enforce corporate policy in a > managed environment, not to defend against a malicious actor with local > administrative rights. ## Centralized configuration: The system settings file The most powerful tools for enterprise administration are the system-wide settings files. These files allow you to define a baseline configuration (`system-defaults.json`) and a set of overrides (`settings.json`) that apply to all users on a machine. For a complete overview of configuration options, see the [Configuration documentation](../reference/configuration.md). Settings are merged from four files. The precedence order for single-value settings (like `theme`) is: 1. System Defaults (`system-defaults.json`) 2. User Settings (`~/.gemini/settings.json`) 3. Workspace Settings (`/.gemini/settings.json`) 4. System Overrides (`settings.json`) This means the System Overrides file has the final say. For settings that are arrays (`includeDirectories`) or objects (`mcpServers`), the values are merged. **Example of merging and precedence:** Here is how settings from different levels are combined. - **System defaults `system-defaults.json`:** ```json { "ui": { "theme": "default-corporate-theme" }, "context": { "includeDirectories": ["/etc/gemini-cli/common-context"] } } ``` - **User `settings.json` (`~/.gemini/settings.json`):** ```json { "ui": { "theme": "user-preferred-dark-theme" }, "mcpServers": { "corp-server": { "command": "/usr/local/bin/corp-server-dev" }, "user-tool": { "command": "npm start --prefix ~/tools/my-tool" } }, "context": { "includeDirectories": ["~/gemini-context"] } } ``` - **Workspace `settings.json` (`/.gemini/settings.json`):** ```json { "ui": { "theme": "project-specific-light-theme" }, "mcpServers": { "project-tool": { "command": "npm start" } }, "context": { "includeDirectories": ["./project-context"] } } ``` - **System overrides `settings.json`:** ```json { "ui": { "theme": "system-enforced-theme" }, "mcpServers": { "corp-server": { "command": "/usr/local/bin/corp-server-prod" } }, "context": { "includeDirectories": ["/etc/gemini-cli/global-context"] } } ``` This results in the following merged configuration: - **Final merged configuration:** ```json { "ui": { "theme": "system-enforced-theme" }, "mcpServers": { "corp-server": { "command": "/usr/local/bin/corp-server-prod" }, "user-tool": { "command": "npm start --prefix ~/tools/my-tool" }, "project-tool": { "command": "npm start" } }, "context": { "includeDirectories": [ "/etc/gemini-cli/common-context", "~/gemini-context", "./project-context", "/etc/gemini-cli/global-context" ] } } ``` **Why:** - **`theme`**: The value from the system overrides (`system-enforced-theme`) is used, as it has the highest precedence. - **`mcpServers`**: The objects are merged. The `corp-server` definition from the system overrides takes precedence over the user's definition. The unique `user-tool` and `project-tool` are included. - **`includeDirectories`**: The arrays are concatenated in the order of System Defaults, User, Workspace, and then System Overrides. - **Location**: - **Linux**: `/etc/gemini-cli/settings.json` - **Windows**: `C:\ProgramData\gemini-cli\settings.json` - **macOS**: `/Library/Application Support/GeminiCli/settings.json` - The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable. - **Control**: This file should be managed by system administrators and protected with appropriate file permissions to prevent unauthorized modification by users. By using the system settings file, you can enforce the security and configuration patterns described below. ### Enforcing system settings with a wrapper script While the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable provides flexibility, a user could potentially override it to point to a different settings file, bypassing the centrally managed configuration. To mitigate this, enterprises can deploy a wrapper script or alias that ensures the environment variable is always set to the corporate-controlled path. This approach ensures that no matter how the user calls the `gemini` command, the enterprise settings are always loaded with the highest precedence. **Example wrapper script:** Administrators can create a script named `gemini` and place it in a directory that appears earlier in the user's `PATH` than the actual Gemini CLI binary (e.g., `/usr/local/bin/gemini`). ```bash #!/bin/bash # Enforce the path to the corporate system settings file. # This ensures that the company's configuration is always applied. export GEMINI_CLI_SYSTEM_SETTINGS_PATH="/etc/gemini-cli/settings.json" # Find the original gemini executable. # This is a simple example; a more robust solution might be needed # depending on the installation method. REAL_GEMINI_PATH=$(type -aP gemini | grep -v "^$(type -P gemini)$" | head -n 1) if [ -z "$REAL_GEMINI_PATH" ]; then echo "Error: The original 'gemini' executable was not found." >&2 exit 1 fi # Pass all arguments to the real Gemini CLI executable. exec "$REAL_GEMINI_PATH" "$@" ``` By deploying this script, the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` is set within the script's environment, and the `exec` command replaces the script process with the actual Gemini CLI process, which inherits the environment variable. This makes it significantly more difficult for a user to bypass the enforced settings. **PowerShell Profile (Windows alternative):** On Windows, administrators can achieve similar results by adding the environment variable to the system-wide or user-specific PowerShell profile: ```powershell Add-Content -Path $PROFILE -Value '$env:GEMINI_CLI_SYSTEM_SETTINGS_PATH="C:\ProgramData\gemini-cli\settings.json"' ``` ## User isolation in shared environments In shared compute environments (like ML experiment runners or shared build servers), you can isolate Gemini CLI state by overriding the user's home directory. By default, Gemini CLI stores configuration and history in `~/.gemini`. You can use the `GEMINI_CLI_HOME` environment variable to point to a unique directory for a specific user or job. The CLI will create a `.gemini` folder inside the specified path. **macOS/Linux** ```bash # Isolate state for a specific job export GEMINI_CLI_HOME="/tmp/gemini-job-123" gemini ``` **Windows (PowerShell)** ```powershell # Isolate state for a specific job $env:GEMINI_CLI_HOME="C:\temp\gemini-job-123" gemini ``` ## Restricting tool access You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `tools.core` setting and the [Policy Engine](../reference/policy-engine.md). For a list of available tools, see the [Tools reference](../reference/tools.md). ### Allowlisting with `coreTools` The most secure approach is to explicitly add the tools and commands that users are permitted to execute to an allowlist. This prevents the use of any tool not on the approved list. **Example:** Allow only safe, read-only file operations and listing files. ```json { "tools": { "core": ["ReadFileTool", "GlobTool", "ShellTool(ls)"] } } ``` ### Blocklisting with `excludeTools` (Deprecated) > **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for > more robust control. Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist. **Example:** Prevent the use of the shell tool for removing files. ```json { "tools": { "exclude": ["ShellTool(rm -rf)"] } } ``` > [!WARNING] > Blocklisting with `excludeTools` is less secure than > allowlisting with `coreTools`, as it relies on blocking known-bad commands, > and clever users may find ways to bypass simple string-based blocks. > **Allowlisting is the recommended approach.** ### Disabling YOLO mode To ensure that users cannot bypass the confirmation prompt for tool execution, you can disable YOLO mode at the policy level. This adds a critical layer of safety, as it prevents the model from executing tools without explicit user approval. **Example:** Force all tool executions to require user confirmation. ```json { "security": { "disableYoloMode": true } } ``` This setting is highly recommended in an enterprise environment to prevent unintended tool execution. ## Managing custom tools (MCP servers) If your organization uses custom tools via [Model-Context Protocol (MCP) servers](../tools/mcp-server.md), it is crucial to understand how server configurations are managed to apply security policies effectively. ### How MCP server configurations are merged Gemini CLI loads `settings.json` files from three levels: System, Workspace, and User. When it comes to the `mcpServers` object, these configurations are **merged**: 1. **Merging:** The lists of servers from all three levels are combined into a single list. 2. **Precedence:** If a server with the **same name** is defined at multiple levels (e.g., a server named `corp-api` exists in both system and user settings), the definition from the highest-precedence level is used. The order of precedence is: **System > Workspace > User**. This means a user **cannot** override the definition of a server that is already defined in the system-level settings. However, they **can** add new servers with unique names. ### Enforcing a catalog of tools The security of your MCP tool ecosystem depends on a combination of defining the canonical servers and adding their names to an allowlist. ### Restricting tools within an MCP server For even greater security, especially when dealing with third-party MCP servers, you can restrict which specific tools from a server are exposed to the model. This is done using the `includeTools` and `excludeTools` properties within a server's definition. This allows you to use a subset of tools from a server without allowing potentially dangerous ones. Following the principle of least privilege, it is highly recommended to use `includeTools` to create an allowlist of only the necessary tools. **Example:** Only allow the `code-search` and `get-ticket-details` tools from a third-party MCP server, even if the server offers other tools like `delete-ticket`. ```json { "mcp": { "allowed": ["third-party-analyzer"] }, "mcpServers": { "third-party-analyzer": { "command": "/usr/local/bin/start-3p-analyzer.sh", "includeTools": ["code-search", "get-ticket-details"] } } } ``` #### More secure pattern: Define and add to allowlist in system settings To create a secure, centrally-managed catalog of tools, the system administrator **must** do both of the following in the system-level `settings.json` file: 1. **Define the full configuration** for every approved server in the `mcpServers` object. This ensures that even if a user defines a server with the same name, the secure system-level definition will take precedence. 2. **Add the names** of those servers to an allowlist using the `mcp.allowed` setting. This is a critical security step that prevents users from running any servers that are not on this list. If this setting is omitted, the CLI will merge and allow any server defined by the user. **Example system `settings.json`:** 1. Add the _names_ of all approved servers to an allowlist. This will prevent users from adding their own servers. 2. Provide the canonical _definition_ for each server on the allowlist. ```json { "mcp": { "allowed": ["corp-data-api", "source-code-analyzer"] }, "mcpServers": { "corp-data-api": { "command": "/usr/local/bin/start-corp-api.sh", "timeout": 5000 }, "source-code-analyzer": { "command": "/usr/local/bin/start-analyzer.sh" } } } ``` This pattern is more secure because it uses both definition and an allowlist. Any server a user defines will either be overridden by the system definition (if it has the same name) or blocked because its name is not in the `mcp.allowed` list. ### Less secure pattern: Omitting the allowlist If the administrator defines the `mcpServers` object but fails to also specify the `mcp.allowed` allowlist, users may add their own servers. **Example system `settings.json`:** This configuration defines servers but does not enforce the allowlist. The administrator has NOT included the "mcp.allowed" setting. ```json { "mcpServers": { "corp-data-api": { "command": "/usr/local/bin/start-corp-api.sh" } } } ``` In this scenario, a user can add their own server in their local `settings.json`. Because there is no `mcp.allowed` list to filter the merged results, the user's server will be added to the list of available tools and allowed to run. ## Enforcing sandboxing for security To mitigate the risk of potentially harmful operations, you can enforce the use of sandboxing for all tool execution. The sandbox isolates tool execution in a containerized environment. **Example:** Force all tool execution to happen within a Docker sandbox. ```json { "tools": { "sandbox": "docker" } } ``` You can also specify a custom, hardened Docker image for the sandbox by building a custom `sandbox.Dockerfile` as described in the [Sandboxing documentation](./sandbox.md). ## Controlling network access via proxy In corporate environments with strict network policies, you can configure Gemini CLI to route all outbound traffic through a corporate proxy. This can be set via an environment variable, but it can also be enforced for custom tools via the `mcpServers` configuration. **Example (for an MCP server):** ```json { "mcpServers": { "proxied-server": { "command": "node", "args": ["mcp_server.js"], "env": { "HTTP_PROXY": "http://proxy.example.com:8080", "HTTPS_PROXY": "http://proxy.example.com:8080" } } } } ``` ## Telemetry and auditing For auditing and monitoring purposes, you can configure Gemini CLI to send telemetry data to a central location. This allows you to track tool usage and other events. For more information, see the [telemetry documentation](./telemetry.md). **Example:** Enable telemetry and send it to a local OTLP collector. If `otlpEndpoint` is not specified, it defaults to `http://localhost:4317`. ```json { "telemetry": { "enabled": true, "target": "gcp", "logPrompts": false } } ``` > [!NOTE] > Ensure that `logPrompts` is set to `false` in an enterprise setting to > avoid collecting potentially sensitive information from user prompts. ## Authentication You can enforce a specific authentication method for all users by setting the `enforcedAuthType` in the system-level `settings.json` file. This prevents users from choosing a different authentication method. See the [Authentication docs](../get-started/authentication.md) for more details. **Example:** Enforce the use of Google login for all users. ```json { "enforcedAuthType": "oauth-personal" } ``` If a user has a different authentication method configured, they will be prompted to switch to the enforced method. In non-interactive mode, the CLI will exit with an error if the configured authentication method does not match the enforced one. ### Restricting logins to corporate domains For enterprises using Google Workspace, you can enforce that users only authenticate with their corporate Google accounts. This is a network-level control that is configured on a proxy server, not within Gemini CLI itself. It works by intercepting authentication requests to Google and adding a special HTTP header. This policy prevents users from logging in with personal Gmail accounts or other non-corporate Google accounts. For detailed instructions, see the Google Workspace Admin Help article on [blocking access to consumer accounts](https://support.google.com/a/answer/1668854?hl=en#zippy=%2Cstep-choose-a-web-proxy-server%2Cstep-configure-the-network-to-block-certain-accounts). The general steps are as follows: 1. **Intercept Requests**: Configure your web proxy to intercept all requests to `google.com`. 2. **Add HTTP Header**: For each intercepted request, add the `X-GoogApps-Allowed-Domains` HTTP header. 3. **Specify Domains**: The value of the header should be a comma-separated list of your approved Google Workspace domain names. **Example header:** ``` X-GoogApps-Allowed-Domains: my-corporate-domain.com, secondary-domain.com ``` When this header is present, Google's authentication service will only allow logins from accounts belonging to the specified domains. ## Putting it all together: example system `settings.json` Here is an example of a system `settings.json` file that combines several of the patterns discussed above to create a secure, controlled environment for Gemini CLI. ```json { "tools": { "sandbox": "docker", "core": [ "ReadFileTool", "GlobTool", "ShellTool(ls)", "ShellTool(cat)", "ShellTool(grep)" ] }, "mcp": { "allowed": ["corp-tools"] }, "mcpServers": { "corp-tools": { "command": "/opt/gemini-tools/start.sh", "timeout": 5000 } }, "telemetry": { "enabled": true, "target": "gcp", "otlpEndpoint": "https://telemetry-prod.example.com:4317", "logPrompts": false }, "advanced": { "bugCommand": { "urlTemplate": "https://servicedesk.example.com/new-ticket?title={title}&details={info}" } }, "privacy": { "usageStatisticsEnabled": false } } ``` This configuration: - Forces all tool execution into a Docker sandbox. - Strictly uses an allowlist for a small set of safe shell commands and file tools. - Defines and allows a single corporate MCP server for custom tools. - Enables telemetry for auditing, without logging prompt content. - Redirects the `/bug` command to an internal ticketing system. - Disables general usage statistics collection. ================================================ FILE: docs/cli/gemini-ignore.md ================================================ # Ignoring files This document provides an overview of the Gemini Ignore (`.geminiignore`) feature of the Gemini CLI. The Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git). ## How it works When you add a path to your `.geminiignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the `@` command to share files, any paths in your `.geminiignore` file will be automatically excluded. For the most part, `.geminiignore` follows the conventions of `.gitignore` files: - Blank lines and lines starting with `#` are ignored. - Standard glob patterns are supported (such as `*`, `?`, and `[]`). - Putting a `/` at the end will only match directories. - Putting a `/` at the beginning anchors the path relative to the `.geminiignore` file. - `!` negates a pattern. You can update your `.geminiignore` file at any time. To apply the changes, you must restart your Gemini CLI session. ## How to use `.geminiignore` To enable `.geminiignore`: 1. Create a file named `.geminiignore` in the root of your project directory. To add a file or directory to `.geminiignore`: 1. Open your `.geminiignore` file. 2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`. ### `.geminiignore` examples You can use `.geminiignore` to ignore directories and files: ``` # Exclude your /packages/ directory and all subdirectories /packages/ # Exclude your apikeys.txt file apikeys.txt ``` You can use wildcards in your `.geminiignore` file with `*`: ``` # Exclude all .md files *.md ``` Finally, you can exclude files and directories from exclusion with `!`: ``` # Exclude all .md files except README.md *.md !README.md ``` To remove paths from your `.geminiignore` file, delete the relevant lines. ================================================ FILE: docs/cli/gemini-md.md ================================================ # Provide context with GEMINI.md files Context files, which use the default name `GEMINI.md`, are a powerful feature for providing instructional context to the Gemini model. You can use these files to give project-specific instructions, define a persona, or provide coding style guides to make the AI's responses more accurate and tailored to your needs. Instead of repeating instructions in every prompt, you can define them once in a context file. ## Understand the context hierarchy The CLI uses a hierarchical system to source context. It loads various context files from several locations, concatenates the contents of all found files, and sends them to the model with every prompt. The CLI loads files in the following order: 1. **Global context file:** - **Location:** `~/.gemini/GEMINI.md` (in your user home directory). - **Scope:** Provides default instructions for all your projects. 2. **Environment and workspace context files:** - **Location:** The CLI searches for `GEMINI.md` files in your configured workspace directories and their parent directories. - **Scope:** Provides context relevant to the projects you are currently working on. 3. **Just-in-time (JIT) context files:** - **Location:** When a tool accesses a file or directory, the CLI automatically scans for `GEMINI.md` files in that directory and its ancestors up to a trusted root. - **Scope:** Lets the model discover highly specific instructions for particular components only when they are needed. The CLI footer displays the number of loaded context files, which gives you a quick visual cue of the active instructional context. ### Example `GEMINI.md` file Here is an example of what you can include in a `GEMINI.md` file at the root of a TypeScript project: ```markdown # Project: My TypeScript Library ## General Instructions - When you generate new TypeScript code, follow the existing coding style. - Ensure all new functions and classes have JSDoc comments. - Prefer functional programming paradigms where appropriate. ## Coding Style - Use 2 spaces for indentation. - Prefix interface names with `I` (for example, `IUserService`). - Always use strict equality (`===` and `!==`). ``` ## Manage context with the `/memory` command You can interact with the loaded context files by using the `/memory` command. - **`/memory show`**: Displays the full, concatenated content of the current hierarchical memory. This lets you inspect the exact instructional context being provided to the model. - **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files from all configured locations. - **`/memory add `**: Appends your text to your global `~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly. ## Modularize context with imports You can break down large `GEMINI.md` files into smaller, more manageable components by importing content from other files using the `@file.md` syntax. This feature supports both relative and absolute paths. **Example `GEMINI.md` with imports:** ```markdown # Main GEMINI.md file This is the main content. @./components/instructions.md More content here. @../shared/style-guide.md ``` For more details, see the [Memory Import Processor](../reference/memport.md) documentation. ## Customize the context file name While `GEMINI.md` is the default filename, you can configure this in your `settings.json` file. To specify a different name or a list of names, use the `context.fileName` property. **Example `settings.json`:** ```json { "context": { "fileName": ["AGENTS.md", "CONTEXT.md", "GEMINI.md"] } } ``` ## Next steps - Learn about [Ignoring files](./gemini-ignore.md) to exclude content from the context system. - Explore the [Memory tool](../tools/memory.md) to save persistent memories. - See how to use [Custom commands](./custom-commands.md) to automate common prompts. ================================================ FILE: docs/cli/generation-settings.md ================================================ # Advanced Model Configuration This guide details the Model Configuration system within the Gemini CLI. Designed for researchers, AI quality engineers, and advanced users, this system provides a rigorous framework for managing generative model hyperparameters and behaviors. > **Warning**: This is a power-user feature. Configuration values are passed > directly to the model provider with minimal validation. Incorrect settings > (e.g., incompatible parameter combinations) may result in runtime errors from > the API. ## 1. System Overview The Model Configuration system (`ModelConfigService`) enables deterministic control over model generation. It decouples the requested model identifier (e.g., a CLI flag or agent request) from the underlying API configuration. This allows for: - **Precise Hyperparameter Tuning**: Direct control over `temperature`, `topP`, `thinkingBudget`, and other SDK-level parameters. - **Environment-Specific Behavior**: Distinct configurations for different operating contexts (e.g., testing vs. production). - **Agent-Scoped Customization**: Applying specific settings only when a particular agent is active. The system operates on two core primitives: **Aliases** and **Overrides**. ## 2. Configuration Primitives These settings are located under the `modelConfigs` key in your configuration file. ### Aliases (`customAliases`) Aliases are named, reusable configuration presets. Users should define their own aliases (or override system defaults) in the `customAliases` map. - **Inheritance**: An alias can `extends` another alias (including system defaults like `chat-base`), inheriting its `modelConfig`. Child aliases can overwrite or augment inherited settings. - **Abstract Aliases**: An alias is not required to specify a concrete `model` if it serves purely as a base for other aliases. **Example Hierarchy**: ```json "modelConfigs": { "customAliases": { "base": { "modelConfig": { "generateContentConfig": { "temperature": 0.0 } } }, "chat-base": { "extends": "base", "modelConfig": { "generateContentConfig": { "temperature": 0.7 } } } } } ``` ### Overrides (`overrides`) Overrides are conditional rules that inject configuration based on the runtime context. They are evaluated dynamically for each model request. - **Match Criteria**: Overrides apply when the request context matches the specified `match` properties. - `model`: Matches the requested model name or alias. - `overrideScope`: Matches the distinct scope of the request (typically the agent name, e.g., `codebaseInvestigator`). **Example Override**: ```json "modelConfigs": { "overrides": [ { "match": { "overrideScope": "codebaseInvestigator" }, "modelConfig": { "generateContentConfig": { "temperature": 0.1 } } } ] } ``` ## 3. Resolution Strategy The `ModelConfigService` resolves the final configuration through a two-step process: ### Step 1: Alias Resolution The requested model string is looked up in the merged map of system `aliases` and user `customAliases`. 1. If found, the system recursively resolves the `extends` chain. 2. Settings are merged from parent to child (child wins). 3. This results in a base `ResolvedModelConfig`. 4. If not found, the requested string is treated as the raw model name. ### Step 2: Override Application The system evaluates the `overrides` list against the request context (`model` and `overrideScope`). 1. **Filtering**: All matching overrides are identified. 2. **Sorting**: Matches are prioritized by **specificity** (the number of matched keys in the `match` object). - Specific matches (e.g., `model` + `overrideScope`) override broad matches (e.g., `model` only). - Tie-breaking: If specificity is equal, the order of definition in the `overrides` array is preserved (last one wins). 3. **Merging**: The configurations from the sorted overrides are merged sequentially onto the base configuration. ## 4. Configuration Reference The configuration follows the `ModelConfigServiceConfig` interface. ### `ModelConfig` Object Defines the actual parameters for the model. | Property | Type | Description | | :---------------------- | :------- | :----------------------------------------------------------------- | | `model` | `string` | The identifier of the model to be called (e.g., `gemini-2.5-pro`). | | `generateContentConfig` | `object` | The configuration object passed to the `@google/genai` SDK. | ### `GenerateContentConfig` (Common Parameters) Directly maps to the SDK's `GenerateContentConfig`. Common parameters include: - **`temperature`**: (`number`) Controls output randomness. Lower values (0.0) are deterministic; higher values (>0.7) are creative. - **`topP`**: (`number`) Nucleus sampling probability. - **`maxOutputTokens`**: (`number`) Limit on generated response length. - **`thinkingConfig`**: (`object`) Configuration for models with reasoning capabilities (e.g., `thinkingBudget`, `includeThoughts`). ## 5. Practical Examples ### Defining a Deterministic Baseline Create an alias for tasks requiring high precision, extending the standard chat configuration but enforcing zero temperature. ```json "modelConfigs": { "customAliases": { "precise-mode": { "extends": "chat-base", "modelConfig": { "generateContentConfig": { "temperature": 0.0, "topP": 1.0 } } } } } ``` ### Agent-Specific Parameter Injection Enforce extended thinking budgets for a specific agent without altering the global default, e.g. for the `codebaseInvestigator`. ```json "modelConfigs": { "overrides": [ { "match": { "overrideScope": "codebaseInvestigator" }, "modelConfig": { "generateContentConfig": { "thinkingConfig": { "thinkingBudget": 4096 } } } } ] } ``` ### Experimental Model Evaluation Route traffic for a specific alias to a preview model for A/B testing, without changing client code. ```json "modelConfigs": { "overrides": [ { "match": { "model": "gemini-2.5-pro" }, "modelConfig": { "model": "gemini-2.5-pro-experimental-001" } } ] } ``` ================================================ FILE: docs/cli/headless.md ================================================ # Headless mode reference Headless mode provides a programmatic interface to Gemini CLI, returning structured text or JSON output without an interactive terminal UI. ## Technical reference Headless mode is triggered when the CLI is run in a non-TTY environment or when providing a query with the `-p` (or `--prompt`) flag. ### Output formats You can specify the output format using the `--output-format` flag. #### JSON output Returns a single JSON object containing the response and usage statistics. - **Schema:** - `response`: (string) The model's final answer. - `stats`: (object) Token usage and API latency metrics. - `error`: (object, optional) Error details if the request failed. #### Streaming JSON output Returns a stream of newline-delimited JSON (JSONL) events. - **Event types:** - `init`: Session metadata (session ID, model). - `message`: User and assistant message chunks. - `tool_use`: Tool call requests with arguments. - `tool_result`: Output from executed tools. - `error`: Non-fatal warnings and system errors. - `result`: Final outcome with aggregated statistics and per-model token usage breakdowns. ## Exit codes The CLI returns standard exit codes to indicate the result of the headless execution: - `0`: Success. - `1`: General error or API failure. - `42`: Input error (invalid prompt or arguments). - `53`: Turn limit exceeded. ## Next steps - Follow the [Automation tutorial](./tutorials/automation.md) for practical scripting examples. - See the [CLI reference](./cli-reference.md) for all available flags. ================================================ FILE: docs/cli/model-routing.md ================================================ # Model routing Gemini CLI includes a model routing feature that automatically switches to a fallback model in case of a model failure. This feature is enabled by default and provides resilience when the primary model is unavailable. ## How it works Model routing is managed by the `ModelAvailabilityService`, which monitors model health and automatically routes requests to available models based on defined policies. 1. **Model failure:** If the currently selected model fails (e.g., due to quota or server errors), the CLI will initiate the fallback process. 2. **User consent:** Depending on the failure and the model's policy, the CLI may prompt you to switch to a fallback model (by default always prompts you). Some internal utility calls (such as prompt completion and classification) use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the configured model. 3. **Model switch:** If approved, or if the policy allows for silent fallback, the CLI will use an available fallback model for the current turn or the remainder of the session. ### Local Model Routing (Experimental) Gemini CLI supports using a local model for routing decisions. When configured, Gemini CLI will use a locally-running **Gemma** model to make routing decisions (instead of sending routing decisions to a hosted model). This feature can help reduce costs associated with hosted model usage while offering similar routing decision latency and quality. In order to use this feature, the local Gemma model **must** be served behind a Gemini API and accessible via HTTP at an endpoint configured in `settings.json`. For more details on how to configure local model routing, see [Local Model Routing](../core/local-model-routing.md). ### Model selection precedence The model used by Gemini CLI is determined by the following order of precedence: 1. **`--model` command-line flag:** A model specified with the `--model` flag when launching the CLI will always be used. 2. **`GEMINI_MODEL` environment variable:** If the `--model` flag is not used, the CLI will use the model specified in the `GEMINI_MODEL` environment variable. 3. **`model.name` in `settings.json`:** If neither of the above are set, the model specified in the `model.name` property of your `settings.json` file will be used. 4. **Local model (experimental):** If the Gemma local model router is enabled in your `settings.json` file, the CLI will use the local Gemma model (instead of Gemini models) to route the request to an appropriate model. 5. **Default model:** If none of the above are set, the default model will be used. The default model is `auto` ================================================ FILE: docs/cli/model-steering.md ================================================ # Model steering (experimental) Model steering lets you provide real-time guidance and feedback to Gemini CLI while it is actively executing a task. This lets you correct course, add missing context, or skip unnecessary steps without having to stop and restart the agent. > [!NOTE] > This is an experimental feature currently under active development and > may need to be enabled under `/settings`. Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) workflows or long-running subagent executions where you want to ensure the agent stays on the right track. ## Enabling model steering Model steering is an experimental feature and is disabled by default. You can enable it using the `/settings` command or by updating your `settings.json` file. 1. Type `/settings` in the Gemini CLI. 2. Search for **Model Steering**. 3. Set the value to **true**. Alternatively, add the following to your `settings.json`: ```json { "experimental": { "modelSteering": true } } ``` ## Using model steering When model steering is enabled, Gemini CLI treats any text you type while the agent is working as a steering hint. 1. Start a task (for example, "Refactor the database service"). 2. While the agent is working (the spinner is visible), type your feedback in the input box. 3. Press **Enter**. Gemini CLI acknowledges your hint with a brief message and injects it directly into the model's context for the very next turn. The model then re-evaluates its current plan and adjusts its actions accordingly. ### Common use cases You can use steering hints to guide the model in several ways: - **Correcting a path:** "Actually, the utilities are in `src/common/utils`." - **Skipping a step:** "Skip the unit tests for now and just focus on the implementation." - **Adding context:** "The `User` type is defined in `packages/core/types.ts`." - **Redirecting the effort:** "Stop searching the codebase and start drafting the plan now." - **Handling ambiguity:** "Use the existing `Logger` class instead of creating a new one." ## How it works When you submit a steering hint, Gemini CLI performs the following actions: 1. **Immediate acknowledgment:** It uses a small, fast model to generate a one-sentence acknowledgment so you know your hint was received. 2. **Context injection:** It prepends an internal instruction to your hint that tells the main agent to: - Re-evaluate the active plan. - Classify the update (for example, as a new task or extra context). - Apply minimal-diff changes to affected tasks. 3. **Real-time update:** The hint is delivered to the agent at the beginning of its next turn, ensuring the most immediate course correction possible. ## Next steps - Tackle complex tasks with [Plan Mode](./plan-mode.md). - Build custom [Agent Skills](./skills.md). ================================================ FILE: docs/cli/model.md ================================================ # Gemini CLI model selection (`/model` command) Select your Gemini CLI model. The `/model` command lets you configure the model used by Gemini CLI, giving you more control over your results. Use **Pro** models for complex tasks and reasoning, **Flash** models for high speed results, or the (recommended) **Auto** setting to choose the best model for your tasks. > [!NOTE] > The `/model` command (and the `--model` flag) does not override the > model used by sub-agents. Consequently, even when using the `/model` flag you > may see other models used in your model usage reports. ## How to use the `/model` command Use the following command in Gemini CLI: ``` /model ``` Running this command will open a dialog with your options: | Option | Description | Models | | ----------------- | -------------------------------------------------------------- | -------------------------------------------- | | Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview | | Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | | Manual | Select a specific model. | Any available model. | We recommend selecting one of the above **Auto** options. However, you can select **Manual** to select a specific model from those available. You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the [configuration documentation](../reference/configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. ## Best practices for model selection - **Default to Auto.** For most users, the _Auto_ option model provides a balance between speed and performance, automatically selecting the correct model based on the complexity of the task. Example: Developing a web application could include a mix of complex tasks (building architecture and scaffolding the project) and simple tasks (generating CSS). - **Switch to Pro if you aren't getting the results you want.** If you think you need your model to be a little "smarter," you can manually select Pro. Pro will provide you with the highest levels of reasoning and creativity. Example: A complex or multi-stage debugging task. - **Switch to Flash or Flash-Lite if you need faster results.** If you need a simple response quickly, Flash or Flash-Lite is the best option. Example: Converting a JSON object to a YAML string. ================================================ FILE: docs/cli/notifications.md ================================================ # Notifications (experimental) Gemini CLI can send system notifications to alert you when a session completes or when it needs your attention, such as when it's waiting for you to approve a tool call. > [!NOTE] > This is an experimental feature currently under active development and > may need to be enabled under `/settings`. Notifications are particularly useful when running long-running tasks or using [Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini CLI works in the background. ## Requirements Currently, system notifications are only supported on macOS. ### Terminal support The CLI uses the OSC 9 terminal escape sequence to trigger system notifications. This is supported by several modern terminal emulators. If your terminal does not support OSC 9 notifications, Gemini CLI falls back to a system alert sound to get your attention. ## Enable notifications Notifications are disabled by default. You can enable them using the `/settings` command or by updating your `settings.json` file. 1. Open the settings dialog by typing `/settings` in an interactive session. 2. Navigate to the **General** category. 3. Toggle the **Enable Notifications** setting to **On**. Alternatively, add the following to your `settings.json`: ```json { "general": { "enableNotifications": true } } ``` ## Types of notifications Gemini CLI sends notifications for the following events: - **Action required:** Triggered when the model is waiting for user input or tool approval. This helps you know when the CLI has paused and needs you to intervene. - **Session complete:** Triggered when a session finishes successfully. This is useful for tracking the completion of automated tasks. ## Next steps - Start planning with [Plan Mode](./plan-mode.md). - Configure your experience with other [settings](./settings.md). ================================================ FILE: docs/cli/plan-mode.md ================================================ # Plan Mode Plan Mode is a read-only environment for architecting robust solutions before implementation. With Plan Mode, you can: - **Research:** Explore the project in a read-only state to prevent accidental changes. - **Design:** Understand problems, evaluate trade-offs, and choose a solution. - **Plan:** Align on an execution strategy before any code is modified. Plan Mode is enabled by default. You can manage this setting using the `/settings` command. ## How to enter Plan Mode Plan Mode integrates seamlessly into your workflow, letting you switch between planning and execution as needed. You can either configure Gemini CLI to start in Plan Mode by default or enter Plan Mode manually during a session. ### Launch in Plan Mode To start Gemini CLI directly in Plan Mode by default: 1. Use the `/settings` command. 2. Set **Default Approval Mode** to `Plan`. To launch Gemini CLI in Plan Mode once: 1. Use `gemini --approval-mode=plan` when launching Gemini CLI. ### Enter Plan Mode manually To start Plan Mode while using Gemini CLI: - **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from the rotation when Gemini CLI is actively processing or showing confirmation dialogs. - **Command:** Type `/plan` in the input box. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool to switch modes. This tool is not available when Gemini CLI is in [YOLO mode](../reference/configuration.md#command-line-arguments). ## How to use Plan Mode Plan Mode lets you collaborate with Gemini CLI to design a solution before Gemini CLI takes action. 1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI will then enter Plan Mode (if it's not already) to research the task. 2. **Review research and provide input:** As Gemini CLI analyzes your codebase, it may ask you questions or present different implementation options using [`ask_user`](../tools/ask-user.md). Provide your preferences to help guide the design. 3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a detailed implementation plan as a Markdown file in your plans directory. - **View:** You can open and read this file to understand the proposed changes. - **Edit:** Press `Ctrl+X` to open the plan directly in your configured external editor. 4. **Approve or iterate:** Gemini CLI will present the finalized plan for your approval. - **Approve:** If you're satisfied with the plan, approve it to start the implementation immediately: **Yes, automatically accept edits** or **Yes, manually accept edits**. - **Iterate:** If the plan needs adjustments, provide feedback in the input box or [edit the plan file directly](#collaborative-plan-editing). Gemini CLI will refine the strategy and update the plan. - **Cancel:** You can cancel your plan with `Esc`. For more complex or specialized planning tasks, you can [customize the planning workflow with skills](#custom-planning-with-skills). ### Collaborative plan editing You can collaborate with Gemini CLI by making direct changes or leaving comments in the implementation plan. This is often faster and more precise than describing complex changes in natural language. 1. **Open the plan:** Press `Ctrl+X` when Gemini CLI presents a plan for review. 2. **Edit or comment:** The plan opens in your configured external editor (for example, VS Code or Vim). You can: - **Modify steps:** Directly reorder, delete, or rewrite implementation steps. - **Leave comments:** Add inline questions or feedback (for example, "Wait, shouldn't we use the existing `Logger` class here?"). 3. **Save and close:** Save your changes and close the editor. 4. **Review and refine:** Gemini CLI automatically detects the changes, reviews your comments, and adjusts the implementation strategy. It then presents the refined plan for your final approval. ## How to exit Plan Mode You can exit Plan Mode at any time, whether you have finalized a plan or want to switch back to another mode. - **Approve a plan:** When Gemini CLI presents a finalized plan, approving it automatically exits Plan Mode and starts the implementation. - **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode. - **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning." ## Tool Restrictions Plan Mode enforces strict safety policies to prevent accidental changes. These are the only allowed tools: - **FileSystem (Read):** [`read_file`](../tools/file-system.md#2-read_file-readfile), [`list_directory`](../tools/file-system.md#1-list_directory-readfolder), [`glob`](../tools/file-system.md#4-glob-findfiles) - **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), [`google_web_search`](../tools/web-search.md), [`get_internal_docs`](../tools/internal-docs.md) - **Research Subagents:** [`codebase_investigator`](../core/subagents.md#codebase-investigator), [`cli_help`](../core/subagents.md#cli-help-agent) - **Interaction:** [`ask_user`](../tools/ask-user.md) - **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for example, `github_read_issue`, `postgres_read_schema`) are allowed. - **Planning (Write):** [`write_file`](../tools/file-system.md#3-write_file-writefile) and [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md` files in the `~/.gemini/tmp///plans/` directory or your [custom plans directory](#custom-plan-directory-and-policies). - **Memory:** [`save_memory`](../tools/memory.md) - **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized instructions and resources in a read-only manner) ## Customization and best practices Plan Mode is secure by default, but you can adapt it to fit your specific workflows. You can customize how Gemini CLI plans by using skills, adjusting safety policies, changing where plans are stored, or adding hooks. ### Custom planning with skills You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI approaches planning for specific types of tasks. When a skill is activated during Plan Mode, its specialized instructions and procedural workflows will guide the research, design, and planning phases. For example: - A **"Database Migration"** skill could ensure the plan includes data safety checks and rollback strategies. - A **"Security Audit"** skill could prompt Gemini CLI to look for specific vulnerabilities during codebase exploration. - A **"Frontend Design"** skill could guide Gemini CLI to use specific UI components and accessibility standards in its proposal. To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the `` skill to plan..." or Gemini CLI may autonomously activate it based on the task description. ### Custom policies Plan Mode's default tool restrictions are managed by the [policy engine](../reference/policy-engine.md) and defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state, but you can customize these rules by creating your own policies in your `~/.gemini/policies/` directory (Tier 2). #### Global vs. mode-specific rules As described in the [policy engine documentation](../reference/policy-engine.md#approval-modes), any rule that does not explicitly specify `modes` is considered "always active" and will apply to Plan Mode as well. If you want a rule to apply to other modes but _not_ to Plan Mode, you must explicitly specify the target modes. For example, to allow `npm test` in default and Auto-Edit modes but not in Plan Mode: ```toml [[rule]] toolName = "run_shell_command" commandPrefix = "npm test" decision = "allow" priority = 100 # By omitting "plan", this rule will not be active in Plan Mode. modes = ["default", "autoEdit"] ``` #### Example: Automatically approve read-only MCP tools By default, read-only MCP tools require user confirmation in Plan Mode. You can use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for your specific environment. `~/.gemini/policies/mcp-read-only.toml` ```toml [[rule]] mcpName = "*" toolAnnotations = { readOnlyHint = true } decision = "allow" priority = 100 modes = ["plan"] ``` For more information on how the policy engine works, see the [policy engine](../reference/policy-engine.md) docs. #### Example: Allow git commands in Plan Mode This rule lets you check the repository status and see changes while in Plan Mode. `~/.gemini/policies/git-research.toml` ```toml [[rule]] toolName = "run_shell_command" commandPrefix = ["git status", "git diff"] decision = "allow" priority = 100 modes = ["plan"] ``` #### Example: Enable custom subagents in Plan Mode Built-in research [subagents](../core/subagents.md) like [`codebase_investigator`](../core/subagents.md#codebase-investigator) and [`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan Mode. You can enable additional [custom subagents](../core/subagents.md#creating-custom-subagents) by adding a rule to your policy. `~/.gemini/policies/research-subagents.toml` ```toml [[rule]] toolName = "my_custom_subagent" decision = "allow" priority = 100 modes = ["plan"] ``` Tell Gemini CLI it can use these tools in your prompt, for example: _"You can check ongoing changes in git."_ ### Custom plan directory and policies By default, planning artifacts are stored in a managed temporary directory outside your project: `~/.gemini/tmp///plans/`. You can configure a custom directory for plans in your `settings.json`. For example, to store plans in a `.gemini/plans` directory within your project: ```json { "general": { "plan": { "directory": ".gemini/plans" } } } ``` To maintain the safety of Plan Mode, user-configured paths for the plans directory are restricted to the project root. This ensures that custom planning locations defined within a project's workspace cannot be used to escape and overwrite sensitive files elsewhere. Any user-configured directory must reside within the project boundary. Using a custom directory requires updating your [policy engine](../reference/policy-engine.md) configurations to allow `write_file` and `replace` in that specific location. For example, to allow writing to the `.gemini/plans` directory within your project, create a policy file at `~/.gemini/policies/plan-custom-directory.toml`: ```toml [[rule]] toolName = ["write_file", "replace"] decision = "allow" priority = 100 modes = ["plan"] # Adjust the pattern to match your custom directory. # This example matches any .md file in a .gemini/plans directory within the project. argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"" ``` ### Using hooks with Plan Mode You can use the [hook system](../hooks/writing-hooks.md) to automate parts of the planning workflow or enforce additional checks when Gemini CLI transitions into or out of Plan Mode. Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the `enter_plan_mode` and `exit_plan_mode` tool calls. > [!WARNING] When hooks are triggered by **tool executions**, they do **not** > run when you manually toggle Plan Mode using the `/plan` command or the > `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes, > ensure the transition is initiated by the agent (e.g., by asking "start a plan > for..."). #### Example: Archive approved plans to GCS (`AfterTool`) If your organizational policy requires a record of all execution plans, you can use an `AfterTool` hook to securely copy the plan artifact to Google Cloud Storage whenever Gemini CLI exits Plan Mode to start the implementation. **`.gemini/hooks/archive-plan.sh`:** ```bash #!/usr/bin/env bash # Extract the plan path from the tool input JSON plan_path=$(jq -r '.tool_input.plan_path // empty') if [ -f "$plan_path" ]; then # Generate a unique filename using a timestamp filename="$(date +%s)_$(basename "$plan_path")" # Upload the plan to GCS in the background so it doesn't block the CLI gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 & fi # AfterTool hooks should generally allow the flow to continue echo '{"decision": "allow"}' ``` To register this `AfterTool` hook, add it to your `settings.json`: ```json { "hooks": { "AfterTool": [ { "matcher": "exit_plan_mode", "hooks": [ { "name": "archive-plan", "type": "command", "command": "./.gemini/hooks/archive-plan.sh" } ] } ] } } ``` ## Commands - **`/plan copy`**: Copy the currently approved plan to your clipboard. ## Planning workflows Plan Mode provides building blocks for structured research and design. These are implemented as [extensions](../extensions/index.md) using core planning tools like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and [`ask_user`](../tools/ask-user.md). ### Built-in planning workflow The built-in planner uses an adaptive workflow to analyze your project, consult you on trade-offs via [`ask_user`](../tools/ask-user.md), and draft a plan for your approval. ### Custom planning workflows You can install or create specialized planners to suit your workflow. #### Conductor [Conductor] is designed for spec-driven development. It organizes work into "tracks" and stores persistent artifacts in your project's `conductor/` directory: - **Automate transitions:** Switches to read-only mode via [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode). - **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) for architectural choices. - **Maintain project context:** Stores artifacts in the project directory using [custom plan directory and policies](#custom-plan-directory-and-policies). - **Handoff execution:** Transitions to implementation via [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode). #### Build your own Since Plan Mode is built on modular building blocks, you can develop your own custom planning workflow as an [extensions](../extensions/index.md). By leveraging core tools and [custom policies](#custom-policies), you can define how Gemini CLI researches and stores plans for your specific domain. To build a custom planning workflow, you can use: - **Tool usage:** Use core tools like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), [`ask_user`](../tools/ask-user.md), and [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) to manage the research and design process. - **Customization:** Set your own storage locations and policy rules using [custom plan directories](#custom-plan-directory-and-policies) and [custom policies](#custom-policies). > [!TIP] > Use [Conductor] as a reference when building your own custom > planning workflow. By using Plan Mode as its execution environment, your custom methodology can enforce read-only safety during the design phase while benefiting from high-reasoning model routing. ## Automatic Model Routing When using an [auto model](../reference/configuration.md#model), Gemini CLI automatically optimizes [model routing](../cli/telemetry.md#model-routing) based on the current phase of your task: 1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a high-reasoning **Pro** model to ensure robust architectural decisions and high-quality plans. 2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode, the CLI detects the existence of the approved plan and automatically switches to a high-speed **Flash** model. This provides a faster, more responsive experience during the implementation of the plan. This behavior is enabled by default to provide the best balance of quality and performance. You can disable this automatic switching in your settings: ```json { "general": { "plan": { "modelRouting": false } } } ``` ## Cleanup By default, Gemini CLI automatically cleans up old session data, including all associated plan files and task trackers. - **Default behavior:** Sessions (and their plans) are retained for **30 days**. - **Configuration:** You can customize this behavior via the `/settings` command (search for **Session Retention**) or in your `settings.json` file. See [session retention](../cli/session-management.md#session-retention) for more details. Manual deletion also removes all associated artifacts: - **Command Line:** Use `gemini --delete-session `. - **Session Browser:** Press `/resume`, navigate to a session, and press `x`. If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. ## Non-interactive execution When running Gemini CLI in non-interactive environments (such as headless scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: - **Automatic transitions:** The policy engine automatically approves the `enter_plan_mode` and `exit_plan_mode` tools without prompting for user confirmation. - **Automated implementation:** When exiting Plan Mode to execute the plan, Gemini CLI automatically switches to [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the standard Default mode. This allows the CLI to execute the implementation steps automatically without hanging on interactive tool approvals. **Example:** ```bash gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" ``` [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [Conductor]: https://github.com/gemini-cli-extensions/conductor [open an issue]: https://github.com/google-gemini/gemini-cli/issues ================================================ FILE: docs/cli/rewind.md ================================================ # Rewind The `/rewind` command lets you go back to a previous state in your conversation and, optionally, revert any file changes made by the AI during those interactions. This is a powerful tool for undoing mistakes, exploring different approaches, or simply cleaning up your session history. ## Usage To use the rewind feature, simply type `/rewind` into the input prompt and press **Enter**. Alternatively, you can use the keyboard shortcut: **Press `Esc` twice**. ## Interface When you trigger a rewind, an interactive list of your previous interactions appears. 1. **Select interaction:** Use the **Up/Down arrow keys** to navigate through the list. The most recent interactions are at the bottom. 2. **Preview:** As you select an interaction, you'll see a preview of the user prompt and, if applicable, the number of files changed during that step. 3. **Confirm selection:** Press **Enter** on the interaction you want to rewind back to. 4. **Action selection:** After selecting an interaction, you'll be presented with a confirmation dialog with up to three options: - **Rewind conversation and revert code changes:** Reverts both the chat history and the file modifications to the state before the selected interaction. - **Rewind conversation:** Only reverts the chat history. File changes are kept. - **Revert code changes:** Only reverts the file modifications. The chat history is kept. - **Do nothing (esc):** Cancels the rewind operation. If no code changes were made since the selected point, the options related to reverting code changes will be hidden. ## Key considerations - **Destructive action:** Rewinding is a destructive action for your current session history and potentially your files. Use it with care. - **Agent awareness:** When you rewind the conversation, the AI model loses all memory of the interactions that were removed. If you only revert code changes, you may need to inform the model that the files have changed. - **Manual edits:** Rewinding only affects file changes made by the AI's edit tools. It does **not** undo manual edits you've made or changes triggered by the shell tool (`!`). - **Compression:** Rewind works across chat compression points by reconstructing the history from stored session data. ================================================ FILE: docs/cli/sandbox.md ================================================ # Sandboxing in the Gemini CLI This document provides a guide to sandboxing in the Gemini CLI, including prerequisites, quickstart, and configuration. ## Prerequisites Before using sandboxing, you need to install and set up the Gemini CLI: ```bash npm install -g @google/gemini-cli ``` To verify the installation: ```bash gemini --version ``` ## Overview of sandboxing Sandboxing isolates potentially dangerous operations (such as shell commands or file modifications) from your host system, providing a security barrier between AI operations and your environment. The benefits of sandboxing include: - **Security**: Prevent accidental system damage or data loss. - **Isolation**: Limit file system access to project directory. - **Consistency**: Ensure reproducible environments across different systems. - **Safety**: Reduce risk when working with untrusted code or experimental commands. ## Sandboxing methods Your ideal method of sandboxing may differ depending on your platform and your preferred container solution. ### 1. macOS Seatbelt (macOS only) Lightweight, built-in sandboxing using `sandbox-exec`. **Default profile**: `permissive-open` - restricts writes outside project directory but allows most other operations. ### 2. Container-based (Docker/Podman) Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. ### 3. Windows Native Sandbox (Windows only) ... **Troubleshooting and Side Effects:** The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory Level" on files and directories it needs to write to. - **Persistence**: These integrity level changes are persistent on the filesystem. Even after the sandbox session ends, files created or modified by the sandbox will retain their "Low" integrity level. - **Manual Reset**: If you need to reset the integrity level of a file or directory, you can use: ```powershell icacls "C:\path\to\dir" /setintegritylevel Medium ``` - **System Folders**: The sandbox manager automatically skips setting integrity levels on system folders (like `C:\Windows`) for safety. ### 4. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container system calls and handles them in a sandboxed kernel written in Go, providing a strong security barrier between AI operations and the host OS. **Prerequisites:** - Linux (gVisor supports Linux only) - Docker installed and running - gVisor/runsc runtime configured When you set `sandbox: "runsc"`, Gemini CLI runs `docker run --runtime=runsc ...` to execute containers with gVisor isolation. runsc is not auto-detected; you must specify it explicitly (e.g. `GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`). To set up runsc: 1. Install the runsc binary. 2. Configure the Docker daemon to use the runsc runtime. 3. Verify the installation. ### 4. LXC/LXD (Linux only, experimental) Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC containers run a complete Linux system with `systemd`, `snapd`, and other system services. This is ideal for tools that don't work in standard Docker containers, such as Snapcraft and Rockcraft. **Prerequisites**: - Linux only. - LXC/LXD must be installed (`snap install lxd` or `apt install lxd`). - A container must be created and running before starting Gemini CLI. Gemini does **not** create the container automatically. **Quick setup**: ```bash # Initialize LXD (first time only) lxd init --auto # Create and start an Ubuntu container lxc launch ubuntu:24.04 gemini-sandbox # Enable LXC sandboxing export GEMINI_SANDBOX=lxc gemini -p "build the project" ``` **Custom container name**: ```bash export GEMINI_SANDBOX=lxc export GEMINI_SANDBOX_IMAGE=my-snapcraft-container gemini -p "build the snap" ``` **Limitations**: - Linux only (LXC is not available on macOS or Windows). - The container must already exist and be running. - The workspace directory is bind-mounted into the container at the same absolute path — the path must be writable inside the container. - Used with tools like Snapcraft or Rockcraft that require a full system. ## Quickstart ```bash # Enable sandboxing with command flag gemini -s -p "analyze the code structure" ``` **Use environment variable** **macOS/Linux** ```bash export GEMINI_SANDBOX=true gemini -p "run the test suite" ``` **Windows (PowerShell)** ```powershell $env:GEMINI_SANDBOX="true" gemini -p "run the test suite" ``` **Configure in settings.json** ```json { "tools": { "sandbox": "docker" } } ``` ## Configuration ### Enable sandboxing (in order of precedence) 1. **Command flag**: `-s` or `--sandbox` 2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc` 3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). ### macOS Seatbelt profiles Built-in profiles (set via `SEATBELT_PROFILE` env var): - `permissive-open` (default): Write restrictions, network allowed - `permissive-proxied`: Write restrictions, network via proxy - `restrictive-open`: Strict restrictions, network allowed - `restrictive-proxied`: Strict restrictions, network via proxy - `strict-open`: Read and write restrictions, network allowed - `strict-proxied`: Read and write restrictions, network via proxy ### Custom sandbox flags For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases. **Example (Podman)**: To disable SELinux labeling for volume mounts, you can set the following: **macOS/Linux** ```bash export SANDBOX_FLAGS="--security-opt label=disable" ``` **Windows (PowerShell)** ```powershell $env:SANDBOX_FLAGS="--security-opt label=disable" ``` Multiple flags can be provided as a space-separated string: **macOS/Linux** ```bash export SANDBOX_FLAGS="--flag1 --flag2=value" ``` **Windows (PowerShell)** ```powershell $env:SANDBOX_FLAGS="--flag1 --flag2=value" ``` ## Linux UID/GID handling The sandbox automatically handles user permissions on Linux. Override these permissions with: **macOS/Linux** ```bash export SANDBOX_SET_UID_GID=true # Force host UID/GID export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping ``` **Windows (PowerShell)** ```powershell $env:SANDBOX_SET_UID_GID="true" # Force host UID/GID $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping ``` ## Troubleshooting ### Common issues **"Operation not permitted"** - Operation requires access outside sandbox. - Try more permissive profile or add mount points. **Missing commands** - Add to custom Dockerfile. - Install via `sandbox.bashrc`. **Network issues** - Check sandbox profile allows network. - Verify proxy configuration. ### Debug mode ```bash DEBUG=1 gemini -s -p "debug command" ``` > [!NOTE] > If you have `DEBUG=true` in a project's `.env` file, it won't affect > gemini-cli due to automatic exclusion. Use `.gemini/.env` files for > gemini-cli specific debug settings. ### Inspect sandbox ```bash # Check environment gemini -s -p "run shell command: env | grep SANDBOX" # List mounts gemini -s -p "run shell command: mount | grep workspace" ``` ## Security notes - Sandboxing reduces but doesn't eliminate all risks. - Use the most restrictive profile that allows your work. - Container overhead is minimal after first build. - GUI applications may not work in sandboxes. ## Related documentation - [Configuration](../reference/configuration.md): Full configuration options. - [Commands](../reference/commands.md): Available commands. - [Troubleshooting](../resources/troubleshooting.md): General troubleshooting. ================================================ FILE: docs/cli/session-management.md ================================================ # Session management Session management saves your conversation history so you can resume your work where you left off. Use these features to review past interactions, manage history across different projects, and configure how long data is retained. ## Automatic saving Your session history is recorded automatically as you interact with the model. This background process ensures your work is preserved even if you interrupt a session. - **What is saved:** The complete conversation history, including: - Your prompts and the model's responses. - All tool executions (inputs and outputs). - Token usage statistics (input, output, cached, etc.). - Assistant thoughts and reasoning summaries (when available). - **Location:** Sessions are stored in `~/.gemini/tmp//chats/`, where `` is a unique identifier based on your project's root directory. - **Scope:** Sessions are project-specific. Switching directories to a different project switches to that project's session history. ## Resuming sessions You can resume a previous session to continue the conversation with all prior context restored. Resuming is supported both through command-line flags and an interactive browser. ### From the command line When starting Gemini CLI, use the `--resume` (or `-r`) flag to load existing sessions. - **Resume latest:** ```bash gemini --resume ``` This immediately loads the most recent session. - **Resume by index:** List available sessions first (see [Listing sessions](#listing-sessions)), then use the index number: ```bash gemini --resume 1 ``` - **Resume by ID:** You can also provide the full session UUID: ```bash gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` ### From the interactive interface While the CLI is running, use the `/resume` slash command to open the **Session Browser**: ```text /resume ``` When typing `/resume` (or `/chat`) in slash completion, commands are grouped under titled separators: - `-- auto --` (session browser) - `list` is selectable and opens the session browser - `-- checkpoints --` (manual tagged checkpoint commands) Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu. The Session Browser provides an interactive interface where you can perform the following actions: - **Browse:** Scroll through a list of your past sessions. - **Preview:** See details like the session date, message count, and the first user prompt. - **Search:** Press `/` to enter search mode, then type to filter sessions by ID or content. - **Select:** Press **Enter** to resume the selected session. - **Esc:** Press **Esc** to exit the Session Browser. ### Manual chat checkpoints For named branch points inside a session, use chat checkpoints: ```text /resume save decision-point /resume list /resume resume decision-point ``` Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. ## Managing sessions You can list and delete sessions to keep your history organized and manage disk space. ### Listing sessions To see a list of all available sessions for the current project from the command line, use the `--list-sessions` flag: ```bash gemini --list-sessions ``` Output example: ```text Available sessions for this project (3): 1. Fix bug in auth (2 days ago) [a1b2c3d4] 2. Refactor database schema (5 hours ago) [e5f67890] 3. Update documentation (Just now) [abcd1234] ``` ### Deleting sessions You can remove old or unwanted sessions to free up space or declutter your history. **From the command line:** Use the `--delete-session` flag with an index or ID: ```bash gemini --delete-session 2 ``` **From the Session Browser:** 1. Open the browser with `/resume`. 2. Navigate to the session you want to remove. 3. Press **x**. ## Configuration You can configure how Gemini CLI manages your session history in your `settings.json` file. These settings let you control retention policies and session lengths. ### Session retention By default, Gemini CLI automatically cleans up old session data to prevent your history from growing indefinitely. When a session is deleted, Gemini CLI also removes all associated data, including implementation plans, task trackers, tool outputs, and activity logs. The default policy is to **retain sessions for 30 days**. #### Configuration You can customize these policies using the `/settings` command or by manually editing your `settings.json` file: ```json { "general": { "sessionRetention": { "enabled": true, "maxAge": "30d", "maxCount": 50 } } } ``` - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to `true`. - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", "4w"). Sessions older than this are deleted. Defaults to `"30d"`. - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest sessions exceeding this count are deleted. Defaults to undefined (unlimited). - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults to `"1d"`. Sessions newer than this period are never deleted by automatic cleanup. ### Session limits You can limit the length of individual sessions to prevent context windows from becoming too large and expensive. ```json { "model": { "maxSessionTurns": 100 } } ``` - **`maxSessionTurns`**: (number) The maximum number of turns (user and model exchanges) allowed in a single session. Set to `-1` for unlimited (default). **Behavior when limit is reached:** - **Interactive mode:** The CLI shows an informational message and stops sending requests to the model. You must manually start a new session. - **Non-interactive mode:** The CLI exits with an error. ## Next steps - Explore the [Memory tool](../tools/memory.md) to save persistent information across sessions. - Learn how to [Checkpoint](./checkpointing.md) your session state. - Check out the [CLI reference](./cli-reference.md) for all command-line flags. ================================================ FILE: docs/cli/settings.md ================================================ # Gemini CLI settings (`/settings` command) Control your Gemini CLI experience with the `/settings` command. The `/settings` command opens a dialog to view and edit all your Gemini CLI settings, including your UI experience, keybindings, and accessibility features. Your Gemini CLI settings are stored in a `settings.json` file. In addition to using the `/settings` command, you can also edit them in one of the following locations: - **User settings**: `~/.gemini/settings.json` - **Workspace settings**: `your-project/.gemini/settings.json` > [!IMPORTANT] > Workspace settings override user settings. ## Settings reference Here is a list of all the available settings, grouped by category and ordered as they appear in the UI. ### General | UI Label | Setting | Description | Default | | ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | | Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | | Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | | Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | | Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | | Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output | UI Label | Setting | Description | Default | | ------------- | --------------- | ------------------------------------------------------ | -------- | | Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `"text"` | ### UI | UI Label | Setting | Description | Default | | ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | | Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | | Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | | Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | | Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | | Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | | Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | | Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | | Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | | Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | | Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | | Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | | Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | | Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | | Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | | Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | | Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | | Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | | Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | | Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | | Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | | Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | | Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | | Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | | Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE | UI Label | Setting | Description | Default | | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | ### Billing | UI Label | Setting | Description | Default | | ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | ### Model | UI Label | Setting | Description | Default | | ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | | Model | `model.name` | The Gemini model to use for conversations. | `undefined` | | Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | | Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | | Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | ### Context | UI Label | Setting | Description | Default | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | | Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | | Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | | Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | | Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | | Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` | | Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` | ### Tools | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | | Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` | | Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | ### Security | UI Label | Setting | Description | Default | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | | Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | | Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` | | Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | | Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | | Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | | Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` | | Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` | | Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | | Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` | ### Advanced | UI Label | Setting | Description | Default | | --------------------------------- | ------------------------------ | --------------------------------------------- | ------- | | Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` | ### Experimental | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | | Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | | Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills | UI Label | Setting | Description | Default | | ------------------- | ---------------- | -------------------- | ------- | | Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` | ### HooksConfig | UI Label | Setting | Description | Default | | ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- | | Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` | | Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` | ================================================ FILE: docs/cli/skills.md ================================================ # Agent Skills Agent Skills allow you to extend Gemini CLI with specialized expertise, procedural workflows, and task-specific resources. Based on the [Agent Skills](https://agentskills.io) open standard, a "skill" is a self-contained directory that packages instructions and assets into a discoverable capability. ## Overview Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide persistent workspace-wide background, Skills represent **on-demand expertise**. This allows Gemini to maintain a vast library of specialized capabilities—such as security auditing, cloud deployments, or codebase migrations—without cluttering the model's immediate context window. Gemini autonomously decides when to employ a skill based on your request and the skill's description. When a relevant skill is identified, the model "pulls in" the full instructions and resources required to complete the task using the `activate_skill` tool. ## Key Benefits - **Shared Expertise:** Package complex workflows (like a specific team's PR review process) into a folder that anyone can use. - **Repeatable Workflows:** Ensure complex multi-step tasks are performed consistently by providing a procedural framework. - **Resource Bundling:** Include scripts, templates, or example data alongside instructions so the agent has everything it needs. - **Progressive Disclosure:** Only skill metadata (name and description) is loaded initially. Detailed instructions and resources are only disclosed when the model explicitly activates the skill, saving context tokens. ## Skill Discovery Tiers Gemini CLI discovers skills from three primary locations: 1. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/` alias. Workspace skills are typically committed to version control and shared with the team. 2. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/` alias. These are personal skills available across all your workspaces. 3. **Extension Skills**: Skills bundled within installed [extensions](../extensions/index.md). **Precedence:** If multiple skills share the same name, higher-precedence locations override lower ones: **Workspace > User > Extension**. Within the same tier (user or workspace), the `.agents/skills/` alias takes precedence over the `.gemini/skills/` directory. This generic alias provides an intuitive path for managing agent-specific expertise that remains compatible across different AI agent tools. ## Managing Skills ### In an Interactive Session Use the `/skills` slash command to view and manage available expertise: - `/skills list` (default): Shows all discovered skills and their status. - `/skills link `: Links agent skills from a local directory via symlink. - `/skills disable `: Prevents a specific skill from being used. - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. > [!NOTE] > `/skills disable` and `/skills enable` default to the `user` scope. Use > `--scope workspace` to manage workspace-specific settings. ### From the Terminal The `gemini skills` command provides management utilities: ```bash # List all discovered skills gemini skills list # Link agent skills from a local directory via symlink # Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills # (or ~/.agents/skills) gemini skills link /path/to/my-skills-repo # Link to the workspace scope (.gemini/skills or .agents/skills) gemini skills link /path/to/my-skills-repo --scope workspace # Install a skill from a Git repository, local directory, or zipped skill file (.skill) # Uses the user scope by default (~/.gemini/skills or ~/.agents/skills) gemini skills install https://github.com/user/repo.git gemini skills install /path/to/local/skill gemini skills install /path/to/local/my-expertise.skill # Install a specific skill from a monorepo or subdirectory using --path gemini skills install https://github.com/my-org/my-skills.git --path skills/frontend-design # Install to the workspace scope (.gemini/skills or .agents/skills) gemini skills install /path/to/skill --scope workspace # Uninstall a skill by name gemini skills uninstall my-expertise --scope workspace # Enable a skill (globally) gemini skills enable my-expertise # Disable a skill. Can use --scope to specify workspace or user (defaults to workspace) gemini skills disable my-expertise --scope workspace ``` ## How it Works 1. **Discovery**: At the start of a session, Gemini CLI scans the discovery tiers and injects the name and description of all enabled skills into the system prompt. 2. **Activation**: When Gemini identifies a task matching a skill's description, it calls the `activate_skill` tool. 3. **Consent**: You will see a confirmation prompt in the UI detailing the skill's name, purpose, and the directory path it will gain access to. 4. **Injection**: Upon your approval: - The `SKILL.md` body and folder structure is added to the conversation history. - The skill's directory is added to the agent's allowed file paths, granting it permission to read any bundled assets. 5. **Execution**: The model proceeds with the specialized expertise active. It is instructed to prioritize the skill's procedural guidance within reason. ### Skill activation Once a skill is activated (typically by Gemini identifying a task that matches the skill's description and your approval), its specialized instructions and resources are loaded into the agent's context. A skill remains active and its guidance is prioritized for the duration of the session. ## Creating your own skills To create your own skills, see the [Create Agent Skills](./creating-skills.md) guide. ================================================ FILE: docs/cli/system-prompt.md ================================================ # System Prompt Override (GEMINI_SYSTEM_MD) The core system instructions that guide Gemini CLI can be completely replaced with your own Markdown file. This feature is controlled via the `GEMINI_SYSTEM_MD` environment variable. ## Overview The `GEMINI_SYSTEM_MD` variable instructs the CLI to use an external Markdown file for its system prompt, completely overriding the built-in default. This is a full replacement, not a merge. If you use a custom file, none of the original core instructions will apply unless you include them yourself. This feature is intended for advanced users who need to enforce strict, project-specific behavior or create a customized persona. > [!TIP] > You can export the current default system prompt to a file first, review > it, and then selectively modify or replace it (see > [“Export the default prompt”](#export-the-default-prompt-recommended)). ## How to enable You can set the environment variable temporarily in your shell, or persist it via a `.gemini/.env` file. See [Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables). - Use the project default path (`.gemini/system.md`): - `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1` - The CLI reads `./.gemini/system.md` (relative to your current project directory). - Use a custom file path: - `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md` - Relative paths are supported and resolved from the current working directory. - Tilde expansion is supported (e.g., `~/my-system.md`). - Disable the override (use built‑in prompt): - `GEMINI_SYSTEM_MD=false` or `GEMINI_SYSTEM_MD=0` or unset the variable. If the override is enabled but the target file does not exist, the CLI will error with: `missing system prompt file ''`. ## Quick examples - One‑off session using a project file: - `GEMINI_SYSTEM_MD=1 gemini` - Persist for a project using `.gemini/.env`: - Create `.gemini/system.md`, then add to `.gemini/.env`: - `GEMINI_SYSTEM_MD=1` - Use a custom file under your home directory: - `GEMINI_SYSTEM_MD=~/prompts/SYSTEM.md gemini` ## UI indicator When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|⌐■_■|` indicator in the UI to signal custom system‑prompt mode. ## Variable Substitution When using a custom system prompt file, you can use the following variables to dynamically include built-in content: - `${AgentSkills}`: Injects a complete section (including header) of all available agent skills. - `${SubAgents}`: Injects a complete section (including header) of available sub-agents. - `${AvailableTools}`: Injects a bulleted list of all currently enabled tool names. - Tool Name Variables: Injects the actual name of a tool using the pattern: `${toolName}_ToolName` (e.g., `${write_file_ToolName}`, `${run_shell_command_ToolName}`). This pattern is generated dynamically for all available tools. ### Example ```markdown # Custom System Prompt You are a helpful assistant. ${AgentSkills} ${SubAgents} ## Tooling The following tools are available to you: ${AvailableTools} You can use ${write_file_ToolName} to save logs. ``` ## Export the default prompt (recommended) Before overriding, export the current default prompt so you can review required safety and workflow rules. - Write the built‑in prompt to the project default path: - `GEMINI_WRITE_SYSTEM_MD=1 gemini` - Or write to a custom path: - `GEMINI_WRITE_SYSTEM_MD=~/prompts/DEFAULT_SYSTEM.md gemini` This creates the file and writes the current built‑in system prompt to it. ## Best practices: SYSTEM.md vs GEMINI.md - SYSTEM.md (firmware): - Non‑negotiable operational rules: safety, tool‑use protocols, approvals, and mechanics that keep the CLI reliable. - Stable across tasks and projects (or per project when needed). - GEMINI.md (strategy): - Persona, goals, methodologies, and project/domain context. - Evolves per task; relies on SYSTEM.md for safe execution. Keep SYSTEM.md minimal but complete for safety and tool operation. Keep GEMINI.md focused on high‑level guidance and project specifics. ## Troubleshooting - Error: `missing system prompt file '…'` - Ensure the referenced path exists and is readable. - For `GEMINI_SYSTEM_MD=1|true`, create `./.gemini/system.md` in your project. - Override not taking effect - Confirm the variable is loaded (use `.gemini/.env` or export in your shell). - Paths are resolved from the current working directory; try an absolute path. - Restore defaults - Unset `GEMINI_SYSTEM_MD` or set it to `0`/`false`. ================================================ FILE: docs/cli/telemetry.md ================================================ # Observability with OpenTelemetry Observability is the key to turning experimental AI into reliable software. Gemini CLI provides built-in support for OpenTelemetry, transforming every agent interaction into a rich stream of logs, metrics, and traces. This three-pillar approach gives you the high-fidelity visibility needed to understand agent behavior, optimize performance, and ensure reliability across your entire workflow. Whether you are debugging a complex tool interaction locally or monitoring enterprise-wide usage in the cloud, Gemini CLI's observability system provides the actionable intelligence needed to move from "black box" AI to predictable, high-performance systems. ## OpenTelemetry integration Gemini CLI integrates with **[OpenTelemetry]**, a vendor-neutral, industry-standard observability framework. The observability system provides: - Universal compatibility: Export to any OpenTelemetry backend (Google Cloud, Jaeger, Prometheus, Datadog, etc.). - Standardized data: Use consistent formats and collection methods across your toolchain. - Future-proof integration: Connect with existing and future observability infrastructure. - No vendor lock-in: Switch between backends without changing your instrumentation. [OpenTelemetry]: https://opentelemetry.io/ ## Configuration You control telemetry behavior through the `.gemini/settings.json` file. Environment variables can override these settings. | Setting | Environment Variable | Description | Values | Default | | -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- | | `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` | | `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | | `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` | | `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | | `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | | `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | | `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | | `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` | | - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - | **Note on boolean environment variables:** For boolean settings like `enabled`, setting the environment variable to `true` or `1` enables the feature. For detailed configuration information, see the [Configuration guide](../reference/configuration.md). ## Google Cloud telemetry You can export telemetry data directly to Google Cloud Trace, Cloud Monitoring, and Cloud Logging. ### Prerequisites You must complete several setup steps before enabling Google Cloud telemetry. 1. Set your Google Cloud project ID: - To send telemetry to a separate project: **macOS/Linux** ```bash export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" ``` **Windows (PowerShell)** ```powershell $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" ``` - To send telemetry to the same project as inference: **macOS/Linux** ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" ``` **Windows (PowerShell)** ```powershell $env:GOOGLE_CLOUD_PROJECT="your-project-id" ``` 2. Authenticate with Google Cloud using one of these methods: - **Method A: Application Default Credentials (ADC)**: Use this method for service accounts or standard `gcloud` authentication. - For user accounts: ```bash gcloud auth application-default login ``` - For service accounts: **macOS/Linux** ```bash export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" ``` **Windows (PowerShell)** ```powershell $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json" ``` * **Method B: CLI Auth** (Direct export only): Simplest method for local users. Gemini CLI uses the same OAuth credentials you used for login. To enable this, set `useCliAuth: true` in your `.gemini/settings.json`: ```json { "telemetry": { "enabled": true, "target": "gcp", "useCliAuth": true } } ``` > [!NOTE] > This setting requires **Direct export** (in-process exporters) > and cannot be used when `useCollector` is `true`. If both are enabled, > telemetry will be disabled. 3. Ensure your account or service account has these IAM roles: - Cloud Trace Agent - Monitoring Metric Writer - Logs Writer 4. Enable the required Google Cloud APIs: ```bash gcloud services enable \ cloudtrace.googleapis.com \ monitoring.googleapis.com \ logging.googleapis.com \ --project="$OTLP_GOOGLE_CLOUD_PROJECT" ``` ### Direct export We recommend using direct export to send telemetry directly to Google Cloud services. 1. Enable telemetry in `.gemini/settings.json`: ```json { "telemetry": { "enabled": true, "target": "gcp" } } ``` 2. Run Gemini CLI and send prompts. 3. View logs, metrics, and traces in the Google Cloud Console. See [View Google Cloud telemetry](#view-google-cloud-telemetry) for details. ### View Google Cloud telemetry After you enable telemetry and run Gemini CLI, you can view your data in the Google Cloud Console. - **Logs:** [Logs Explorer](https://console.cloud.google.com/logs/) - **Metrics:** [Metrics Explorer](https://console.cloud.google.com/monitoring/metrics-explorer) - **Traces:** [Trace Explorer](https://console.cloud.google.com/traces/list) For detailed information on how to use these tools, see the following official Google Cloud documentation: - [View and analyze logs with Logs Explorer](https://cloud.google.com/logging/docs/view/logs-explorer-interface) - [Create charts with Metrics Explorer](https://cloud.google.com/monitoring/charts/metrics-explorer) - [Find and explore traces](https://cloud.google.com/trace/docs/finding-traces) #### Monitoring dashboards Gemini CLI provides a pre-configured [Google Cloud Monitoring](https://cloud.google.com/monitoring) dashboard to visualize your telemetry. Find this dashboard under **Google Cloud Monitoring Dashboard Templates** as "**Gemini CLI Monitoring**". ![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png) ![Gemini CLI Monitoring Dashboard Metrics](/docs/assets/monitoring-dashboard-metrics.png) ![Gemini CLI Monitoring Dashboard Logs](/docs/assets/monitoring-dashboard-logs.png) To learn more, see [Instant insights: Gemini CLI’s pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/). ## Local telemetry You can capture telemetry data locally for development and debugging. We recommend using file-based output for local development. 1. Enable telemetry in `.gemini/settings.json`: ```json { "telemetry": { "enabled": true, "target": "local", "outfile": ".gemini/telemetry.log" } } ``` 2. Run Gemini CLI and send prompts. 3. View logs and metrics in `.gemini/telemetry.log`. For advanced local telemetry setups (such as Jaeger or Genkit), see the [Local development guide](../local-development.md#viewing-traces). ## Client identification Gemini CLI includes identifiers in its `User-Agent` header to help you differentiate and report on API traffic from different environments (for example, identifying calls from Gemini Code Assist versus a standard terminal). ### Automatic identification Most integrated environments are identified automatically without additional configuration. The identifier is included as a prefix to the `User-Agent` and as a "surface" tag in the parenthetical metadata. | Environment | User-Agent Prefix | Surface Tag | | :---------------------------------- | :--------------------------- | :---------- | | **Gemini Code Assist (Agent Mode)** | `GeminiCLI-a2a-server` | `vscode` | | **Zed (via ACP)** | `GeminiCLI-acp-zed` | `zed` | | **XCode (via ACP)** | `GeminiCLI-acp-xcode` | `xcode` | | **IntelliJ IDEA (via ACP)** | `GeminiCLI-acp-intellijidea` | `jetbrains` | | **Standard Terminal** | `GeminiCLI` | `terminal` | **Example User-Agent:** `GeminiCLI-a2a-server/0.34.0/gemini-pro (linux; x64; vscode)` ### Custom identification You can provide a custom identifier for your own scripts or automation by setting the `GEMINI_CLI_SURFACE` environment variable. This is useful for tracking specific internal tools or distribution channels in your GCP logs. **macOS/Linux** ```bash export GEMINI_CLI_SURFACE="my-custom-tool" ``` **Windows (PowerShell)** ```powershell $env:GEMINI_CLI_SURFACE="my-custom-tool" ``` When set, the value appears at the end of the `User-Agent` parenthetical: `GeminiCLI/0.34.0/gemini-pro (linux; x64; my-custom-tool)` ## Logs, metrics, and traces This section describes the structure of logs, metrics, and traces generated by Gemini CLI. Gemini CLI includes `session.id`, `installation.id`, `active_approval_mode`, and `user.email` (when authenticated) as common attributes on all data. ### Logs Logs provide timestamped records of specific events. Gemini CLI logs events across several categories. #### Sessions Session logs capture startup configuration and prompt submissions. ##### `gemini_cli.config` Emitted at startup with the CLI configuration.
Attributes - `model` (string) - `embedding_model` (string) - `sandbox_enabled` (boolean) - `core_tools_enabled` (string) - `approval_mode` (string) - `api_key_enabled` (boolean) - `vertex_ai_enabled` (boolean) - `log_user_prompts_enabled` (boolean) - `file_filtering_respect_git_ignore` (boolean) - `debug_mode` (boolean) - `mcp_servers` (string) - `mcp_servers_count` (int) - `mcp_tools` (string) - `mcp_tools_count` (int) - `output_format` (string) - `extensions` (string) - `extension_ids` (string) - `extensions_count` (int) - `auth_type` (string) - `github_workflow_name` (string, optional) - `github_repository_hash` (string, optional) - `github_event_name` (string, optional) - `github_pr_number` (string, optional) - `github_issue_number` (string, optional) - `github_custom_tracking_id` (string, optional)
##### `gemini_cli.user_prompt` Emitted when you submit a prompt.
Attributes - `prompt_length` (int) - `prompt_id` (string) - `prompt` (string; excluded if `telemetry.logPrompts` is `false`) - `auth_type` (string)
#### Approval mode These logs track changes to and usage of different approval modes. ##### Lifecycle ##### `approval_mode_switch` Logs when you change the approval mode.
Attributes - `from_mode` (string) - `to_mode` (string)
##### `approval_mode_duration` Records time spent in an approval mode.
Attributes - `mode` (string) - `duration_ms` (int)
##### Execution ##### `plan_execution` Logs when you execute a plan and switch from plan mode to active execution.
Attributes - `approval_mode` (string)
#### Tools Tool logs capture executions, truncation, and edit behavior. ##### `gemini_cli.tool_call` Emitted for each tool (function) call.
Attributes - `function_name` (string) - `function_args` (string) - `duration_ms` (int) - `success` (boolean) - `decision` (string: "accept", "reject", "auto_accept", or "modify") - `error` (string, optional) - `error_type` (string, optional) - `prompt_id` (string) - `tool_type` (string: "native" or "mcp") - `mcp_server_name` (string, optional) - `extension_name` (string, optional) - `extension_id` (string, optional) - `content_length` (int, optional) - `start_time` (number, optional) - `end_time` (number, optional) - `metadata` (object, optional), which may include: - `model_added_lines` (number) - `model_removed_lines` (number) - `user_added_lines` (number) - `user_removed_lines` (number) - `ask_user` (object)
##### `gemini_cli.tool_output_truncated` Logs when tool output is truncated.
Attributes - `tool_name` (string) - `original_content_length` (int) - `truncated_content_length` (int) - `threshold` (int) - `lines` (int) - `prompt_id` (string)
##### `gemini_cli.edit_strategy` Records the chosen edit strategy.
Attributes - `strategy` (string)
##### `gemini_cli.edit_correction` Records the result of an edit correction.
Attributes - `correction` (string: "success" or "failure")
##### `gen_ai.client.inference.operation.details` Provides detailed GenAI operation data aligned with OpenTelemetry conventions.
Attributes - `gen_ai.request.model` (string) - `gen_ai.provider.name` (string) - `gen_ai.operation.name` (string) - `gen_ai.input.messages` (json string) - `gen_ai.output.messages` (json string) - `gen_ai.response.finish_reasons` (array of strings) - `gen_ai.usage.input_tokens` (int) - `gen_ai.usage.output_tokens` (int) - `gen_ai.request.temperature` (float) - `gen_ai.request.top_p` (float) - `gen_ai.request.top_k` (int) - `gen_ai.request.max_tokens` (int) - `gen_ai.system_instructions` (json string) - `server.address` (string) - `server.port` (int)
#### Files File logs track operations performed by tools. ##### `gemini_cli.file_operation` Emitted for each file creation, read, or update.
Attributes - `tool_name` (string) - `operation` (string: "create", "read", or "update") - `lines` (int, optional) - `mimetype` (string, optional) - `extension` (string, optional) - `programming_language` (string, optional)
#### API API logs capture requests, responses, and errors from Gemini API. ##### `gemini_cli.api_request` Request sent to Gemini API.
Attributes - `model` (string) - `prompt_id` (string) - `role` (string: "user", "model", or "system") - `request_text` (string, optional)
##### `gemini_cli.api_response` Response received from Gemini API.
Attributes - `model` (string) - `status_code` (int or string) - `duration_ms` (int) - `input_token_count` (int) - `output_token_count` (int) - `cached_content_token_count` (int) - `thoughts_token_count` (int) - `tool_token_count` (int) - `total_token_count` (int) - `prompt_id` (string) - `auth_type` (string) - `finish_reasons` (array of strings) - `response_text` (string, optional)
##### `gemini_cli.api_error` Logs when an API request fails.
Attributes - `error.message` (string) - `model_name` (string) - `duration` (int) - `prompt_id` (string) - `auth_type` (string) - `error_type` (string, optional) - `status_code` (int or string, optional) - `role` (string, optional)
##### `gemini_cli.malformed_json_response` Logs when a JSON response cannot be parsed.
Attributes - `model` (string)
#### Model routing These logs track how Gemini CLI selects and routes requests to models. ##### `gemini_cli.slash_command` Logs slash command execution.
Attributes - `command` (string) - `subcommand` (string, optional) - `status` (string: "success" or "error")
##### `gemini_cli.slash_command.model` Logs model selection via slash command.
Attributes - `model_name` (string)
##### `gemini_cli.model_routing` Records model router decisions and reasoning.
Attributes - `decision_model` (string) - `decision_source` (string) - `routing_latency_ms` (int) - `reasoning` (string, optional) - `failed` (boolean) - `error_message` (string, optional) - `approval_mode` (string)
#### Chat and streaming These logs track chat context compression and streaming chunk errors. ##### `gemini_cli.chat_compression` Logs chat context compression events.
Attributes - `tokens_before` (int) - `tokens_after` (int)
##### `gemini_cli.chat.invalid_chunk` Logs invalid chunks received in a stream.
Attributes - `error_message` (string, optional)
##### `gemini_cli.chat.content_retry` Logs retries due to content errors.
Attributes - `attempt_number` (int) - `error_type` (string) - `retry_delay_ms` (int) - `model` (string)
##### `gemini_cli.chat.content_retry_failure` Logs when all content retries fail.
Attributes - `total_attempts` (int) - `final_error_type` (string) - `total_duration_ms` (int, optional) - `model` (string)
##### `gemini_cli.conversation_finished` Logs when a conversation session ends.
Attributes - `approvalMode` (string) - `turnCount` (int)
#### Resilience Resilience logs record fallback mechanisms and recovery attempts. ##### `gemini_cli.flash_fallback` Logs switch to a flash model fallback.
Attributes - `auth_type` (string)
##### `gemini_cli.ripgrep_fallback` Logs fallback to standard grep.
Attributes - `error` (string, optional)
##### `gemini_cli.web_fetch_fallback_attempt` Logs web-fetch fallback attempts.
Attributes - `reason` (string: "private_ip" or "primary_failed")
##### `gemini_cli.agent.recovery_attempt` Logs attempts to recover from agent errors.
Attributes - `agent_name` (string) - `attempt_number` (int) - `success` (boolean) - `error_type` (string, optional)
#### Extensions Extension logs track lifecycle events and settings changes. ##### `gemini_cli.extension_install` Logs when you install an extension.
Attributes - `extension_name` (string) - `extension_version` (string) - `extension_source` (string) - `status` (string)
##### `gemini_cli.extension_uninstall` Logs when you uninstall an extension.
Attributes - `extension_name` (string) - `status` (string)
##### `gemini_cli.extension_enable` Logs when you enable an extension.
Attributes - `extension_name` (string) - `setting_scope` (string)
##### `gemini_cli.extension_disable` Logs when you disable an extension.
Attributes - `extension_name` (string) - `setting_scope` (string)
#### Agent runs Agent logs track the lifecycle of agent executions. ##### `gemini_cli.agent.start` Logs when an agent run begins.
Attributes - `agent_id` (string) - `agent_name` (string)
##### `gemini_cli.agent.finish` Logs when an agent run completes.
Attributes - `agent_id` (string) - `agent_name` (string) - `duration_ms` (int) - `turn_count` (int) - `terminate_reason` (string)
#### IDE IDE logs capture connectivity events for the IDE companion. ##### `gemini_cli.ide_connection` Logs IDE companion connections.
Attributes - `connection_type` (string)
#### UI UI logs track terminal rendering issues. ##### `kitty_sequence_overflow` Logs terminal control sequence overflows.
Attributes - `sequence_length` (int) - `truncated_sequence` (string)
#### Miscellaneous ##### `gemini_cli.rewind` Logs when the conversation state is rewound.
Attributes - `outcome` (string)
##### `gemini_cli.conseca.verdict` Logs security verdicts from ConSeca.
Attributes - `verdict` (string) - `decision` (string: "accept", "reject", or "modify") - `reason` (string, optional) - `tool_name` (string, optional)
##### `gemini_cli.hook_call` Logs execution of lifecycle hooks.
Attributes - `hook_name` (string) - `hook_type` (string) - `duration_ms` (int) - `success` (boolean)
##### `gemini_cli.tool_output_masking` Logs when tool output is masked for privacy.
Attributes - `tokens_before` (int) - `tokens_after` (int) - `masked_count` (int) - `total_prunable_tokens` (int)
##### `gemini_cli.keychain.availability` Logs keychain availability checks.
Attributes - `available` (boolean)
### Metrics Metrics provide numerical measurements of behavior over time. #### Custom metrics Gemini CLI exports several custom metrics. ##### Sessions ##### `gemini_cli.session.count` Incremented once per CLI startup. ##### Tools ##### `gemini_cli.tool.call.count` Counts tool calls.
Attributes - `function_name` (string) - `success` (boolean) - `decision` (string: "accept", "reject", "modify", or "auto_accept") - `tool_type` (string: "mcp" or "native")
##### `gemini_cli.tool.call.latency` Measures tool call latency (in ms).
Attributes - `function_name` (string)
##### API ##### `gemini_cli.api.request.count` Counts all API requests.
Attributes - `model` (string) - `status_code` (int or string) - `error_type` (string, optional)
##### `gemini_cli.api.request.latency` Measures API request latency (in ms).
Attributes - `model` (string)
##### Token usage ##### `gemini_cli.token.usage` Counts input, output, thought, cache, and tool tokens.
Attributes - `model` (string) - `type` (string: "input", "output", "thought", "cache", or "tool")
##### Files ##### `gemini_cli.file.operation.count` Counts file operations.
Attributes - `operation` (string: "create", "read", or "update") - `lines` (int, optional) - `mimetype` (string, optional) - `extension` (string, optional) - `programming_language` (string, optional)
##### `gemini_cli.lines.changed` Counts added or removed lines.
Attributes - `function_name` (string, optional) - `type` (string: "added" or "removed")
##### Chat and streaming ##### `gemini_cli.chat_compression` Counts compression operations.
Attributes - `tokens_before` (int) - `tokens_after` (int)
##### `gemini_cli.chat.invalid_chunk.count` Counts invalid stream chunks. ##### `gemini_cli.chat.content_retry.count` Counts content error retries. ##### `gemini_cli.chat.content_retry_failure.count` Counts requests where all retries failed. ##### Model routing ##### `gemini_cli.slash_command.model.call_count` Counts model selections.
Attributes - `slash_command.model.model_name` (string)
##### `gemini_cli.model_routing.latency` Measures routing decision latency.
Attributes - `routing.decision_model` (string) - `routing.decision_source` (string) - `routing.approval_mode` (string)
##### `gemini_cli.model_routing.failure.count` Counts routing failures.
Attributes - `routing.decision_source` (string) - `routing.error_message` (string) - `routing.approval_mode` (string)
##### Agent runs ##### `gemini_cli.agent.run.count` Counts agent runs.
Attributes - `agent_name` (string) - `terminate_reason` (string)
##### `gemini_cli.agent.duration` Measures agent run duration.
Attributes - `agent_name` (string)
##### `gemini_cli.agent.turns` Counts turns per agent run.
Attributes - `agent_name` (string)
##### Approval mode ##### `gemini_cli.plan.execution.count` Counts plan executions.
Attributes - `approval_mode` (string)
##### UI ##### `gemini_cli.ui.flicker.count` Counts terminal flicker events. ##### Performance Gemini CLI provides detailed performance metrics for advanced monitoring. ##### `gemini_cli.startup.duration` Measures startup time by phase.
Attributes - `phase` (string) - `details` (map, optional)
##### `gemini_cli.memory.usage` Measures heap and RSS memory.
Attributes - `memory_type` (string: "heap_used", "heap_total", "external", "rss") - `component` (string, optional)
##### `gemini_cli.cpu.usage` Measures CPU usage percentage.
Attributes - `component` (string, optional)
##### `gemini_cli.tool.queue.depth` Measures tool execution queue depth. ##### `gemini_cli.tool.execution.breakdown` Breaks down tool time by phase.
Attributes - `function_name` (string) - `phase` (string: "validation", "preparation", "execution", "result_processing")
#### GenAI semantic convention These metrics follow standard [OpenTelemetry GenAI semantic conventions]. - `gen_ai.client.token.usage`: Counts tokens used per operation. - `gen_ai.client.operation.duration`: Measures operation duration in seconds. [OpenTelemetry GenAI semantic conventions]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md ### Traces Traces provide an "under-the-hood" view of agent and backend operations. Use traces to debug tool interactions and optimize performance. Every trace captures rich metadata via standard span attributes.
Standard span attributes - `gen_ai.operation.name`: High-level operation (for example, `tool_call`, `llm_call`, `user_prompt`, `system_prompt`, `agent_call`, or `schedule_tool_calls`). - `gen_ai.agent.name`: Set to `gemini-cli`. - `gen_ai.agent.description`: The service agent description. - `gen_ai.input.messages`: Input data or metadata. - `gen_ai.output.messages`: Output data or results. - `gen_ai.request.model`: Request model name. - `gen_ai.response.model`: Response model name. - `gen_ai.prompt.name`: The prompt name. - `gen_ai.tool.name`: Executed tool name. - `gen_ai.tool.call_id`: Unique ID for the tool call. - `gen_ai.tool.description`: Tool description. - `gen_ai.tool.definitions`: Tool definitions in JSON format. - `gen_ai.usage.input_tokens`: Number of input tokens. - `gen_ai.usage.output_tokens`: Number of output tokens. - `gen_ai.system_instructions`: System instructions in JSON format. - `gen_ai.conversation.id`: The CLI session ID.
For more details on semantic conventions for events, see the [OpenTelemetry documentation](https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md). ================================================ FILE: docs/cli/themes.md ================================================ # Themes Gemini CLI supports a variety of themes to customize its color scheme and appearance. You can change the theme to suit your preferences via the `/theme` command or `"theme":` configuration setting. ## Available themes Gemini CLI comes with a selection of pre-defined themes, which you can list using the `/theme` command within Gemini CLI: - **Dark themes:** - `ANSI` - `Atom One` - `Ayu` - `Default` - `Dracula` - `GitHub` - `Holiday` - `Shades Of Purple` - `Solarized Dark` - **Light themes:** - `ANSI Light` - `Ayu Light` - `Default Light` - `GitHub Light` - `Google Code` - `Solarized Light` - `Xcode` ### Changing themes 1. Enter `/theme` into Gemini CLI. 2. A dialog or selection prompt appears, listing the available themes. 3. Using the arrow keys, select a theme. Some interfaces might offer a live preview or highlight as you select. 4. Confirm your selection to apply the theme. > [!NOTE] > If a theme is defined in your `settings.json` file (either by name or > by a file path), you must remove the `"theme"` setting from the file before > you can change the theme using the `/theme` command. ### Theme persistence Selected themes are saved in Gemini CLI's [configuration](../reference/configuration.md) so your preference is remembered across sessions. --- ## Custom color themes Gemini CLI lets you create your own custom color themes by specifying them in your `settings.json` file. This gives you full control over the color palette used in the CLI. ### How to define a custom theme Add a `customThemes` block to your user, project, or system `settings.json` file. Each custom theme is defined as an object with a unique name and a set of nested configuration objects. For example: ```json { "ui": { "customThemes": { "MyCustomTheme": { "name": "MyCustomTheme", "type": "custom", "background": { "primary": "#181818" }, "text": { "primary": "#f0f0f0", "secondary": "#a0a0a0" } } } } } ``` **Configuration objects:** - **`text`**: Defines text colors. - `primary`: The default text color. - `secondary`: Used for less prominent text. - `link`: Color for URLs and links. - `accent`: Used for highlights and emphasis. - `response`: Precedence over `primary` for rendering model responses. - **`background`**: Defines background colors. - `primary`: The main background color of the UI. - `diff.added`: Background for added lines in diffs. - `diff.removed`: Background for removed lines in diffs. - **`border`**: Defines border colors. - `default`: The standard border color. - `focused`: Border color when an element is focused. - **`status`**: Colors for status indicators. - `success`: Used for successful operations. - `warning`: Used for warnings. - `error`: Used for errors. - **`ui`**: Other UI elements. - `comment`: Color for code comments. - `symbol`: Color for code symbols and operators. - `gradient`: An array of colors used for gradient effects. **Required properties:** - `name` (must match the key in the `customThemes` object and be a string) - `type` (must be the string `"custom"`) While all sub-properties are technically optional, we recommend providing at least `background.primary`, `text.primary`, `text.secondary`, and the various accent colors via `text.link`, `text.accent`, and `status` to ensure a cohesive UI. You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names (e.g., `coral`, `teal`, `blue`) for any color value. See [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#color_keywords) for a full list of supported names. You can define multiple custom themes by adding more entries to the `customThemes` object. ### Loading themes from a file In addition to defining custom themes in `settings.json`, you can also load a theme directly from a JSON file by specifying the file path in your `settings.json`. This is useful for sharing themes or keeping them separate from your main configuration. To load a theme from a file, set the `theme` property in your `settings.json` to the path of your theme file: ```json { "ui": { "theme": "/path/to/your/theme.json" } } ``` The theme file must be a valid JSON file that follows the same structure as a custom theme defined in `settings.json`. **Example `my-theme.json`:** ```json { "name": "Gruvbox Dark", "type": "custom", "background": { "primary": "#282828", "diff": { "added": "#2b3312", "removed": "#341212" } }, "text": { "primary": "#ebdbb2", "secondary": "#a89984", "link": "#83a598", "accent": "#d3869b" }, "border": { "default": "#3c3836", "focused": "#458588" }, "status": { "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934" }, "ui": { "comment": "#928374", "symbol": "#8ec07c", "gradient": ["#cc241d", "#d65d0e", "#d79921"] } } ``` > [!WARNING] > For your safety, Gemini CLI will only load theme files that > are located within your home directory. If you attempt to load a theme from > outside your home directory, a warning will be displayed and the theme will > not be loaded. This is to prevent loading potentially malicious theme files > from untrusted sources. ### Example custom theme Custom theme example ### Using your custom theme - Select your custom theme using the `/theme` command in Gemini CLI. Your custom theme will appear in the theme selection dialog. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. - Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../reference/configuration.md) as other settings. ### Themes from extensions [Extensions](../extensions/reference.md#themes) can also provide custom themes. Once an extension is installed and enabled, its themes are automatically added to the selection list in the `/theme` command. Themes from extensions appear with the extension name in parentheses to help you identify their source, for example: `shades-of-green (green-extension)`. --- ## Dark themes ### ANSI ANSI theme ### Atom One Atom One theme ### Ayu Ayu theme ### Default Default theme ### Dracula Dracula theme ### GitHub GitHub theme ### Holiday Holiday theme ### Shades Of Purple Shades Of Purple theme ### Solarized Dark Solarized Dark theme ## Light themes ### ANSI Light ANSI Light theme ### Ayu Light Ayu Light theme ### Default Light Default Light theme ### GitHub Light GitHub Light theme ### Google Code Google Code theme ### Solarized Light Solarized Light theme ### Xcode Xcode Light theme ================================================ FILE: docs/cli/token-caching.md ================================================ # Token caching and cost optimization Gemini CLI automatically optimizes API costs through token caching when using API key authentication (Gemini API key or Vertex AI). This feature reuses previous system instructions and context to reduce the number of tokens processed in subsequent requests. **Token caching is available for:** - API key users (Gemini API key) - Vertex AI users (with project and location setup) **Token caching is not available for:** - OAuth users (Google Personal/Enterprise accounts) - the Code Assist API does not support cached content creation at this time You can view your token usage and cached token savings using the `/stats` command. When cached tokens are available, they will be displayed in the stats output. ================================================ FILE: docs/cli/trusted-folders.md ================================================ # Trusted Folders The Trusted Folders feature is a security setting that gives you control over which projects can use the full capabilities of the Gemini CLI. It prevents potentially malicious code from running by asking you to approve a folder before the CLI loads any project-specific configurations from it. ## Enabling the feature The Trusted Folders feature is **disabled by default**. To use it, you must first enable it in your settings. Add the following to your user `settings.json` file: ```json { "security": { "folderTrust": { "enabled": true } } } ``` ## How it works: The trust dialog Once the feature is enabled, the first time you run the Gemini CLI from a folder, a dialog will automatically appear, prompting you to make a choice: - **Trust folder**: Grants full trust to the current folder (e.g., `my-project`). - **Trust parent folder**: Grants trust to the parent directory (e.g., `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place. - **Don't trust**: Marks the folder as untrusted. The CLI will operate in a restricted "safe mode." Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you will only be asked once per folder. ## Understanding folder contents: The discovery phase Before you make a choice, the Gemini CLI performs a **discovery phase** to scan the folder for potential configurations. This information is displayed in the trust dialog to help you make an informed decision. The discovery UI lists the following categories of items found in the project: - **Commands**: Custom `.toml` command definitions that add new functionality. - **MCP Servers**: Configured Model Context Protocol servers that the CLI will attempt to connect to. - **Hooks**: System or custom hooks that can intercept and modify CLI behavior. - **Skills**: Local agent skills that provide specialized capabilities. - **Setting overrides**: Any project-specific configurations that override your global user settings. ### Security warnings and errors The trust dialog also highlights critical information that requires your attention: - **Security Warnings**: The CLI will explicitly flag potentially dangerous settings, such as auto-approving certain tools or disabling the security sandbox. - **Discovery Errors**: If the CLI encounters issues while scanning the folder (e.g., a malformed `settings.json` file), these errors will be displayed prominently. By reviewing these details, you can ensure that you only grant trust to projects that you know are safe. ## Why trust matters: The impact of an untrusted workspace When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" to protect you. In this mode, the following features are disabled: 1. **Workspace settings are ignored**: The CLI will **not** load the `.gemini/settings.json` file from the project. This prevents the loading of custom tools and other potentially dangerous configurations. 2. **Environment variables are ignored**: The CLI will **not** load any `.env` files from the project. 3. **Extension management is restricted**: You **cannot install, update, or uninstall** extensions. 4. **Tool auto-acceptance is disabled**: You will always be prompted before any tool is run, even if you have auto-acceptance enabled globally. 5. **Automatic memory loading is disabled**: The CLI will not automatically load files into context from directories specified in local settings. 6. **MCP servers do not connect**: The CLI will not attempt to connect to any [Model Context Protocol (MCP)](../tools/mcp-server.md) servers. 7. **Custom commands are not loaded**: The CLI will not load any custom commands from .toml files, including both project-specific and global user commands. Granting trust to a folder unlocks the full functionality of the Gemini CLI for that workspace. ## Managing your trust settings If you need to change a decision or see all your settings, you have a couple of options: - **Change the current folder's trust**: Run the `/permissions` command from within the CLI. This will bring up the same interactive dialog, allowing you to change the trust level for the current folder. - **View all trust rules**: To see a complete list of all your trusted and untrusted folder rules, you can inspect the contents of the `~/.gemini/trustedFolders.json` file in your home directory. ## The trust check process (advanced) For advanced users, it's helpful to know the exact order of operations for how trust is determined: 1. **IDE trust signal**: If you are using the [IDE Integration](../ide-integration/index.md), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. 2. **Local trust file**: If the IDE is not connected, the CLI checks the central `~/.gemini/trustedFolders.json` file. ================================================ FILE: docs/cli/tutorials/automation.md ================================================ # Automate tasks with headless mode Automate tasks with Gemini CLI. Learn how to use headless mode, pipe data into Gemini CLI, automate workflows with shell scripts, and generate structured JSON output for other applications. ## Prerequisites - Gemini CLI installed and authenticated. - Familiarity with shell scripting (Bash/Zsh). ## Why headless mode? Headless mode runs Gemini CLI once and exits. It's perfect for: - **CI/CD:** Analyzing pull requests automatically. - **Batch processing:** Summarizing a large number of log files. - **Tool building:** Creating your own "AI wrapper" scripts. ## How to use headless mode Run Gemini CLI in headless mode by providing a prompt with the `-p` (or `--prompt`) flag. This bypasses the interactive chat interface and prints the response to standard output (stdout). Positional arguments without the flag default to interactive mode, unless the input or output is piped or redirected. Run a single command: ```bash gemini -p "Write a poem about TypeScript" ``` ## How to pipe input to Gemini CLI Feed data into Gemini using the standard Unix pipe `|`. Gemini reads the standard input (stdin) as context and answers your question using standard output. Pipe a file: **macOS/Linux** ```bash cat error.log | gemini -p "Explain why this failed" ``` **Windows (PowerShell)** ```powershell Get-Content error.log | gemini -p "Explain why this failed" ``` Pipe a command: ```bash git diff | gemini -p "Write a commit message for these changes" ``` ## Use Gemini CLI output in scripts Because Gemini prints to stdout, you can chain it with other tools or save the results to a file. ### Scenario: Bulk documentation generator You have a folder of Python scripts and want to generate a `README.md` for each one. 1. Save the following code as `generate_docs.sh` (or `generate_docs.ps1` for Windows): **macOS/Linux (`generate_docs.sh`)** ```bash #!/bin/bash # Loop through all Python files for file in *.py; do echo "Generating docs for $file..." # Ask Gemini CLI to generate the documentation and print it to stdout gemini -p "Generate a Markdown documentation summary for @$file. Print the result to standard output." > "${file%.py}.md" done ``` **Windows PowerShell (`generate_docs.ps1`)** ```powershell # Loop through all Python files Get-ChildItem -Filter *.py | ForEach-Object { Write-Host "Generating docs for $($_.Name)..." $newName = $_.Name -replace '\.py$', '.md' # Ask Gemini CLI to generate the documentation and print it to stdout gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 } ``` 2. Make the script executable and run it in your directory: **macOS/Linux** ```bash chmod +x generate_docs.sh ./generate_docs.sh ``` **Windows (PowerShell)** ```powershell .\generate_docs.ps1 ``` This creates a corresponding Markdown file for every Python file in the folder. ## Extract structured JSON data When writing a script, you often need structured data (JSON) to pass to tools like `jq`. To get pure JSON data from the model, combine the `--output-format json` flag with `jq` to parse the response field. ### Scenario: Extract and return structured data 1. Save the following script as `generate_json.sh` (or `generate_json.ps1` for Windows): **macOS/Linux (`generate_json.sh`)** ```bash #!/bin/bash # Ensure we are in a project root if [ ! -f "package.json" ]; then echo "Error: package.json not found." exit 1 fi # Extract data gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | jq -r '.response' > data.json ``` **Windows PowerShell (`generate_json.ps1`)** ```powershell # Ensure we are in a project root if (-not (Test-Path "package.json")) { Write-Error "Error: package.json not found." exit 1 } # Extract data (requires jq installed, or you can use ConvertFrom-Json) $output = gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | ConvertFrom-Json $output.response | Out-File -FilePath data.json -Encoding utf8 ``` 2. Run the script: **macOS/Linux** ```bash chmod +x generate_json.sh ./generate_json.sh ``` **Windows (PowerShell)** ```powershell .\generate_json.ps1 ``` 3. Check `data.json`. The file should look like this: ```json { "version": "1.0.0", "deps": { "react": "^18.2.0" } } ``` ## Build your own custom AI tools Use headless mode to perform custom, automated AI tasks. ### Scenario: Create a "Smart Commit" alias You can add a function to your shell configuration to create a `git commit` wrapper that writes the message for you. **macOS/Linux (Bash/Zsh)** 1. Open your `.zshrc` file (or `.bashrc` if you use Bash) in your preferred text editor. ```bash nano ~/.zshrc ``` **Note**: If you use VS Code, you can run `code ~/.zshrc`. 2. Scroll to the very bottom of the file and paste this code: ```bash function gcommit() { # Get the diff of staged changes diff=$(git diff --staged) if [ -z "$diff" ]; then echo "No staged changes to commit." return 1 fi # Ask Gemini to write the message echo "Generating commit message..." msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.") # Commit with the generated message git commit -m "$msg" } ``` Save your file and exit. 3. Run this command to make the function available immediately: ```bash source ~/.zshrc ``` **Windows (PowerShell)** 1. Open your PowerShell profile in your preferred text editor. ```powershell notepad $PROFILE ``` 2. Scroll to the very bottom of the file and paste this code: ```powershell function gcommit { # Get the diff of staged changes $diff = git diff --staged if (-not $diff) { Write-Host "No staged changes to commit." return } # Ask Gemini to write the message Write-Host "Generating commit message..." $msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message." # Commit with the generated message git commit -m "$msg" } ``` Save your file and exit. 3. Run this command to make the function available immediately: ```powershell . $PROFILE ``` 4. Use your new command: ```bash gcommit ``` Gemini CLI will analyze your staged changes and commit them with a generated message. ## Next steps - Explore the [Headless mode reference](../../cli/headless.md) for full JSON schema details. - Learn about [Shell commands](shell-commands.md) to let the agent run scripts instead of just writing them. ================================================ FILE: docs/cli/tutorials/file-management.md ================================================ # File management with Gemini CLI Explore, analyze, and modify your codebase using Gemini CLI. In this guide, you'll learn how to provide Gemini CLI with files and directories, modify and create files, and control what Gemini CLI can see. ## Prerequisites - Gemini CLI installed and authenticated. - A project directory to work with (for example, a git repository). ## Providing context by reading files Gemini CLI will generally try to read relevant files, sometimes prompting you for access (depending on your settings). To ensure that Gemini CLI uses a file, you can also include it directly. ### Direct file inclusion (`@`) If you know the path to the file you want to work on, use the `@` symbol. This forces the CLI to read the file immediately and inject its content into your prompt. ```bash `@src/components/UserProfile.tsx Explain how this component handles user data.` ``` ### Working with multiple files Complex features often span multiple files. You can chain `@` references to give the agent a complete picture of the dependencies. ```bash `@src/components/UserProfile.tsx @src/types/User.ts Refactor the component to use the updated User interface.` ``` ### Including entire directories For broad questions or refactoring, you can include an entire directory. Be careful with large folders, as this consumes more tokens. ```bash `@src/utils/ Check these utility functions for any deprecated API usage.` ``` ## How to find files (Exploration) If you _don't_ know the exact file path, you can ask Gemini CLI to find it for you. This is useful when navigating a new codebase or looking for specific logic. ### Scenario: Find a component definition You know there's a `UserProfile` component, but you don't know where it lives. ```none `Find the file that defines the UserProfile component.` ``` Gemini uses the `glob` or `list_directory` tools to search your project structure. It will return the specific path (for example, `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. > [!TIP] > You can also ask for lists of files, like "Show me all the TypeScript > configuration files in the root directory." ## How to modify code Once Gemini CLI has context, you can direct it to make specific edits. The agent is capable of complex refactoring, not just simple text replacement. ```none `Update @src/components/UserProfile.tsx to show a loading spinner if the user data is null.` ``` Gemini CLI uses the `replace` tool to propose a targeted code change. ### Creating new files You can also ask the agent to create entirely new files or folder structures. ```none `Create a new file @src/components/LoadingSpinner.tsx with a simple Tailwind CSS spinner.` ``` Gemini CLI uses the `write_file` tool to generate the new file from scratch. ## Review and confirm changes Gemini CLI prioritizes safety. Before any file is modified, it presents a unified diff of the proposed changes. ```diff - if (!user) return null; + if (!user) return ; ``` - **Red lines (-):** Code that will be removed. - **Green lines (+):** Code that will be added. Press **y** to confirm and apply the change to your local file system. If the diff doesn't look right, press **n** to cancel and refine your prompt. ## Verify the result After the edit is complete, verify the fix. You can simply read the file again or, better yet, run your project's tests. ```none `Run the tests for the UserProfile component.` ``` Gemini CLI uses the `run_shell_command` tool to execute your test runner (for example, `npm test` or `jest`). This ensures the changes didn't break existing functionality. ## Advanced: Controlling what Gemini sees By default, Gemini CLI respects your `.gitignore` file. It won't read or search through `node_modules`, build artifacts, or other ignored paths. If you have sensitive files (like `.env`) or large assets that you want to keep hidden from the AI _without_ ignoring them in Git, you can create a `.geminiignore` file in your project root. **Example `.geminiignore`:** ```text .env local-db-dump.sql private-notes.md ``` ## Next steps - Learn how to [Manage context and memory](memory-management.md) to keep your agent smarter over long sessions. - See [Execute shell commands](shell-commands.md) for more on running tests and builds. - Explore the technical [File system reference](../../tools/file-system.md) for advanced tool parameters. ================================================ FILE: docs/cli/tutorials/mcp-setup.md ================================================ # Set up an MCP server Connect Gemini CLI to your external databases and services. In this guide, you'll learn how to extend Gemini CLI's capabilities by installing the GitHub MCP server and using it to manage your repositories. ## Prerequisites - Gemini CLI installed. - **Docker:** Required for this specific example (many MCP servers run as Docker containers). - **GitHub token:** A Personal Access Token (PAT) with repo permissions. ## How to prepare your credentials Most MCP servers require authentication. For GitHub, you need a PAT. 1. Create a [fine-grained PAT](https://github.com/settings/tokens?type=beta). 2. Grant it **Read** access to **Metadata** and **Contents**, and **Read/Write** access to **Issues** and **Pull Requests**. 3. Store it in your environment: **macOS/Linux** ```bash export GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." ``` **Windows (PowerShell)** ```powershell $env:GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." ``` ## How to configure Gemini CLI You tell Gemini about new servers by editing your `settings.json`. 1. Open `~/.gemini/settings.json` (or the project-specific `.gemini/settings.json`). 2. Add the `mcpServers` block. This tells Gemini: "Run this docker container and talk to it." ```json { "mcpServers": { "github": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" } } } } ``` > [!NOTE] > The `command` is `docker`, and the rest are arguments passed to it. We > map the local environment variable into the container so your secret isn't > hardcoded in the config file. ## How to verify the connection Restart Gemini CLI. It will automatically try to start the defined servers. **Command:** `/mcp list` You should see: `✓ github: docker ... - Connected` If you see `Disconnected` or an error, check that Docker is running and your API token is valid. ## How to use the new tools Now that the server is running, the agent has new capabilities ("tools"). You don't need to learn special commands; just ask in natural language. ### Scenario: Listing pull requests **Prompt:** `List the open PRs in the google/gemini-cli repository.` The agent will: 1. Recognize the request matches a GitHub tool. 2. Call `mcp_github_list_pull_requests`. 3. Present the data to you. ### Scenario: Creating an issue **Prompt:** `Create an issue in my repo titled "Bug: Login fails" with the description "See logs".` ## Troubleshooting - **Server won't start?** Try running the docker command manually in your terminal to see if it prints an error (e.g., "image not found"). - **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server for its capabilities. ## Next steps - Explore the [MCP servers reference](../../tools/mcp-server.md) to learn about SSE and HTTP transports for remote servers. - Browse the [official MCP server list](https://github.com/modelcontextprotocol/servers) to find connectors for Slack, Postgres, Google Drive, and more. ================================================ FILE: docs/cli/tutorials/memory-management.md ================================================ # Manage context and memory Control what Gemini CLI knows about you and your projects. In this guide, you'll learn how to define project-wide rules with `GEMINI.md`, teach the agent persistent facts, and inspect the active context. ## Prerequisites - Gemini CLI installed and authenticated. - A project directory where you want to enforce specific rules. ## Why manage context? Gemini CLI is powerful but general. It doesn't know your preferred testing framework, your indentation style, or your preference against `any` in TypeScript. Context management solves this by giving the agent persistent memory. You'll use these features when you want to: - **Enforce standards:** Ensure every generated file matches your team's style guide. - **Set a persona:** Tell the agent to act as a "Senior Rust Engineer" or "QA Specialist." - **Remember facts:** Save details like "My database port is 5432" so you don't have to repeat them. ## How to define project-wide rules (GEMINI.md) The most powerful way to control the agent's behavior is through `GEMINI.md` files. These are Markdown files containing instructions that are automatically loaded into every conversation. ### Scenario: Create a project context file 1. In the root of your project, create a file named `GEMINI.md`. 2. Add your instructions: ```markdown # Project Instructions - **Framework:** We use React with Vite. - **Styling:** Use Tailwind CSS for all styling. Do not write custom CSS. - **Testing:** All new components must include a Vitest unit test. - **Tone:** Be concise. Don't explain basic React concepts. ``` 3. Start a new session. Gemini CLI will now know these rules automatically. ### Scenario: Using the hierarchy Context is loaded hierarchically. This allows you to have general rules for everything and specific rules for sub-projects. 1. **Global:** `~/.gemini/GEMINI.md` (Rules for _every_ project you work on). 2. **Project Root:** `./GEMINI.md` (Rules for the current repository). 3. **Subdirectory:** `./src/GEMINI.md` (Rules specific to the `src` folder). **Example:** You might set "Always use strict typing" in your global config, but "Use Python 3.11" only in your backend repository. ## How to teach the agent facts (Memory) Sometimes you don't want to write a config file. You just want to tell the agent something once and have it remember forever. You can do this naturally in chat. ### Scenario: Saving a memory Just tell the agent to remember something. **Prompt:** `Remember that I prefer using 'const' over 'let' wherever possible.` The agent will use the `save_memory` tool to store this fact in your global memory file. **Prompt:** `Save the fact that the staging server IP is 10.0.0.5.` ### Scenario: Using memory in conversation Once a fact is saved, you don't need to invoke it explicitly. The agent "knows" it. **Next Prompt:** `Write a script to deploy to staging.` **Agent Response:** "I'll write a script to deploy to **10.0.0.5**..." ## How to manage and inspect context As your project grows, you might want to see exactly what instructions the agent is following. ### Scenario: View active context To see the full, concatenated set of instructions currently loaded (from all `GEMINI.md` files and saved memories), use the `/memory show` command. **Command:** `/memory show` This prints the raw text the model receives at the start of the session. It's excellent for debugging why the agent might be ignoring a rule. ### Scenario: Refresh context If you edit a `GEMINI.md` file while a session is running, the agent won't know immediately. Force a reload with: **Command:** `/memory reload` ## Best practices - **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep instructions actionable and relevant to code generation. - **Use negative constraints:** Explicitly telling the agent what _not_ to do (for example, "Do not use class components") is often more effective than vague positive instructions. - **Review often:** Periodically check your `GEMINI.md` files to remove outdated rules. ## Next steps - Learn about [Session management](session-management.md) to see how short-term history works. - Explore the [Command reference](../../reference/commands.md) for more `/memory` options. - Read the technical spec for [Project context](../../cli/gemini-md.md). ================================================ FILE: docs/cli/tutorials/plan-mode-steering.md ================================================ # Use Plan Mode with model steering for complex tasks Architecting a complex solution requires precision. By combining Plan Mode's structured environment with model steering's real-time feedback, you can guide Gemini CLI through the research and design phases to ensure the final implementation plan is exactly what you need. > [!NOTE] > This is an experimental feature currently under active development and > may need to be enabled under `/settings`. ## Prerequisites - Gemini CLI installed and authenticated. - [Plan Mode](../plan-mode.md) enabled in your settings. - [Model steering](../model-steering.md) enabled in your settings. ## Why combine Plan Mode and model steering? [Plan Mode](../plan-mode.md) typically follows a linear path: research, propose, and draft. Adding model steering lets you: 1. **Direct the research:** Correct the agent if it's looking in the wrong directory or missing a key dependency. 2. **Iterate mid-draft:** Suggest a different architectural pattern while the agent is still writing the plan. 3. **Speed up the loop:** Avoid waiting for a full research turn to finish before providing critical context. ## Step 1: Start a complex task Enter Plan Mode and start a task that requires research. **Prompt:** `/plan I want to implement a new notification service using Redis.` Gemini CLI enters Plan Mode and starts researching your existing codebase to identify where the new service should live. ## Step 2: Steer the research phase As you see the agent calling tools like `list_directory` or `grep_search`, you might realize it's missing the relevant context. **Action:** While the spinner is active, type your hint: `"Don't forget to check packages/common/queues for the existing Redis config."` **Result:** Gemini CLI acknowledges your hint and immediately incorporates it into its research. You'll see it start exploring the directory you suggested in its very next turn. ## Step 3: Refine the design mid-turn After research, the agent starts drafting the implementation plan. If you notice it's proposing a design that doesn't align with your goals, steer it. **Action:** Type: `"Actually, let's use a Publisher/Subscriber pattern instead of a simple queue for this service."` **Result:** The agent stops drafting the current version of the plan, re-evaluates the design based on your feedback, and starts a new draft that uses the Pub/Sub pattern. ## Step 4: Approve and implement Once the agent has used your hints to craft the perfect plan, review the final `.md` file. **Action:** Type: `"Looks perfect. Let's start the implementation."` Gemini CLI exits Plan Mode and transitions to the implementation phase. Because the plan was refined in real-time with your feedback, the agent can now execute each step with higher confidence and fewer errors. ## Tips for effective steering - **Be specific:** Instead of "do it differently," try "use the existing `Logger` class in `src/utils`." - **Steer early:** Providing feedback during the research phase is more efficient than waiting for the final plan to be drafted. - **Use for context:** Steering is a great way to provide knowledge that might not be obvious from reading the code (e.g., "We are planning to deprecate this module next month"). ## Next steps - Explore [Agent Skills](../skills.md) to add specialized expertise to your planning turns. - See the [Model steering reference](../model-steering.md) for technical details. ================================================ FILE: docs/cli/tutorials/session-management.md ================================================ # Manage sessions and history Resume, browse, and rewind your conversations with Gemini CLI. In this guide, you'll learn how to switch between tasks, manage your session history, and undo mistakes using the rewind feature. ## Prerequisites - Gemini CLI installed and authenticated. - At least one active or past session. ## How to resume where you left off It's common to switch context—maybe you're waiting for a build and want to work on a different feature. Gemini makes it easy to jump back in. ### Scenario: Resume the last session The fastest way to pick up your most recent work is with the `--resume` flag (or `-r`). ```bash gemini -r ``` This restores your chat history and memory, so you can say "Continue with the next step" immediately. ### Scenario: Browse past sessions If you want to find a specific conversation from yesterday, use the interactive browser. **Command:** `/resume` This opens a searchable list of all your past sessions. You'll see: - A timestamp (e.g., "2 hours ago"). - The first user message (helping you identify the topic). - The number of turns in the conversation. Select a session and press **Enter** to load it. ## How to manage your workspace Over time, you'll accumulate a lot of history. Keeping your session list clean helps you find what you need. ### Scenario: Deleting sessions In the `/resume` browser, navigate to a session you no longer need and press **x**. This permanently deletes the history for that specific conversation. You can also manage sessions from the command line: ```bash # List all sessions with their IDs gemini --list-sessions # Delete a specific session by ID or index gemini --delete-session 1 ``` ## How to rewind time (Undo mistakes) Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow. ### Scenario: Triggering rewind At any point in a chat, type `/rewind` or press **Esc** twice. ### Scenario: Choosing a restore point You'll see a list of your recent interactions. Select the point _before_ the undesired changes occurred. ### Scenario: Choosing what to revert Gemini gives you granular control over the undo process. You can choose to: 1. **Rewind conversation:** Only remove the chat history. The files stay changed. (Useful if the code is good but the chat got off track). 2. **Revert code changes:** Keep the chat history but undo the file edits. (Useful if you want to keep the context but retry the implementation). 3. **Rewind both:** Restore everything to exactly how it was. ## How to fork conversations Sometimes you want to try two different approaches to the same problem. 1. Start a session and get to a decision point. 2. Save the current state with `/resume save decision-point`. 3. Try your first approach. 4. Later, use `/resume resume decision-point` to fork the conversation back to that moment and try a different approach. This creates a new branch of history without losing your original work. ## Next steps - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. - See the [Command reference](../../reference/commands.md) for `/resume` options, grouped checkpoint menus, and `/chat` compatibility aliases. ================================================ FILE: docs/cli/tutorials/shell-commands.md ================================================ # Execute shell commands Use the CLI to run builds, manage git, and automate system tasks without leaving the conversation. In this guide, you'll learn how to run commands directly, automate complex workflows, and manage background processes safely. ## Prerequisites - Gemini CLI installed and authenticated. - Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on). ## How to run commands directly (`!`) Sometimes you just need to check a file size or git status without asking the AI to do it for you. You can pass commands directly to your shell using the `!` prefix. **Example:** `!ls -la` This executes `ls -la` immediately and prints the output to your terminal. Gemini CLI also records the command and its output in the current session context, so the model can reference it in follow-up prompts. Very large outputs may be truncated. ### Scenario: Entering Shell mode If you're doing a lot of manual work, toggle "Shell Mode" by typing `!` and pressing **Enter**. Now, everything you type is sent to the shell until you exit (usually by pressing **Esc** or typing `exit`). ## How to automate complex tasks You can automate tasks using a combination of Gemini CLI and shell commands. ### Scenario: Run tests and fix failures You want to run tests and fix any failures. **Prompt:** `Run the unit tests. If any fail, analyze the error and try to fix the code.` **Workflow:** 1. Gemini calls `run_shell_command('npm test')`. 2. You see a confirmation prompt: `Allow command 'npm test'? [y/N]`. 3. You press `y`. 4. The tests run. If they fail, Gemini reads the error output. 5. Gemini uses `read_file` to inspect the failing test. 6. Gemini uses `replace` to fix the bug. 7. Gemini runs `npm test` again to verify the fix. This loop lets Gemini work autonomously. ## How to manage background processes You can ask Gemini to start long-running tasks, like development servers or file watchers. **Prompt:** `Start the React dev server in the background.` Gemini will run the command (e.g., `npm run dev`) and detach it. ### Scenario: Viewing active shells To see what's running in the background, use the `/shells` command. **Command:** `/shells` This opens a dashboard where you can view logs or kill runaway processes. ## How to handle interactive commands Gemini CLI attempts to handle interactive commands (like `git add -p` or confirmation prompts) by streaming the output to you. However, for highly interactive tools (like `vim` or `top`), it's often better to run them yourself in a separate terminal window or use the `!` prefix. ## Safety features Giving an AI access to your shell is powerful but risky. Gemini CLI includes several safety layers. ### Confirmation prompts By default, **every** shell command requested by the agent requires your explicit approval. - **Allow once:** Runs the command one time. - **Allow always:** Trusts this specific command for the rest of the session. - **Deny:** Stops the agent. ### Sandboxing For maximum security, especially when running untrusted code or exploring new projects, we strongly recommend enabling Sandboxing. This runs all shell commands inside a secure Docker container. **Enable sandboxing:** Use the `--sandbox` flag when starting the CLI: `gemini --sandbox`. ## Next steps - Learn about [Sandboxing](../../cli/sandbox.md) to safely run destructive commands. - See the [Shell tool reference](../../tools/shell.md) for configuration options like timeouts and working directories. - Explore [Task planning](task-planning.md) to see how shell commands fit into larger workflows. ================================================ FILE: docs/cli/tutorials/skills-getting-started.md ================================================ # Get started with Agent Skills Agent Skills extend Gemini CLI with specialized expertise. In this guide, you'll learn how to create your first skill, bundle custom scripts, and activate them during a session. ## How to create a skill A skill is defined by a directory containing a `SKILL.md` file. Let's create an **API Auditor** skill that helps you verify if local or remote endpoints are responding correctly. ### Create the directory structure 1. Run the following command to create the folders: **macOS/Linux** ```bash mkdir -p .gemini/skills/api-auditor/scripts ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts" ``` ### Create the definition 1. Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent _when_ to use the skill and _how_ to behave. ```markdown --- name: api-auditor description: Expertise in auditing and testing API endpoints. Use when the user asks to "check", "test", or "audit" a URL or API. --- # API Auditor Instructions You act as a QA engineer specialized in API reliability. When this skill is active, you MUST: 1. **Audit**: Use the bundled `scripts/audit.js` utility to check the status of the provided URL. 2. **Report**: Analyze the output (status codes, latency) and explain any failures in plain English. 3. **Secure**: Remind the user if they are testing a sensitive endpoint without an `https://` protocol. ``` ### Add the tool logic Skills can bundle resources like scripts. 1. Create a file at `.gemini/skills/api-auditor/scripts/audit.js`. This is the code the agent will run. ```javascript // .gemini/skills/api-auditor/scripts/audit.js const url = process.argv[2]; if (!url) { console.error('Usage: node audit.js '); process.exit(1); } console.log(`Auditing ${url}...`); fetch(url, { method: 'HEAD' }) .then((r) => console.log(`Result: Success (Status ${r.status})`)) .catch((e) => console.error(`Result: Failed (${e.message})`)); ``` ## How to verify discovery Gemini CLI automatically discovers skills in the `.gemini/skills` directory. You can also use `.agents/skills` as a more generic alternative. Check that it found your new skill. **Command:** `/skills list` You should see `api-auditor` in the list of available skills. ## How to use the skill Now, try it out. Start a new session and ask a question that triggers the skill's description. **User:** "Can you audit http://geminicli.com" Gemini recognizes the request matches the `api-auditor` description and asks for permission to activate it. **Model:** (After calling `activate_skill`) "I've activated the **api-auditor** skill. I'll run the audit script now..." Gemini then uses the `run_shell_command` tool to execute your bundled Node script: `node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com` ## Next steps - Explore the [Agent Skills Authoring Guide](../../cli/skills.md#creating-a-skill) to learn about more advanced features. - Learn how to share skills via [Extensions](../../extensions/index.md). ================================================ FILE: docs/cli/tutorials/task-planning.md ================================================ # Plan tasks with todos Keep complex jobs on the rails with Gemini CLI's built-in task planning. In this guide, you'll learn how to ask for a plan, execute it step-by-step, and monitor progress with the todo list. ## Prerequisites - Gemini CLI installed and authenticated. - A complex task in mind (e.g., a multi-file refactor or new feature). ## Why use task planning? Standard LLMs have a limited context window and can "forget" the original goal after 10 turns of code generation. Task planning provides: 1. **Visibility:** You see exactly what the agent plans to do _before_ it starts. 2. **Focus:** The agent knows exactly which step it's working on right now. 3. **Resilience:** If the agent gets stuck, the plan helps it get back on track. ## How to ask for a plan The best way to trigger task planning is to explicitly ask for it. **Prompt:** `I want to migrate this project from JavaScript to TypeScript. Please make a plan first.` Gemini will analyze your codebase and use the `write_todos` tool to generate a structured list. **Example Plan:** 1. [ ] Create `tsconfig.json`. 2. [ ] Rename `.js` files to `.ts`. 3. [ ] Fix type errors in `utils.js`. 4. [ ] Fix type errors in `server.js`. 5. [ ] Verify build passes. ## How to review and iterate Once the plan is generated, it appears in your CLI. Review it. - **Missing steps?** Tell the agent: "You forgot to add a step for installing `@types/node`." - **Wrong order?** Tell the agent: "Let's verify the build _after_ each file, not just at the end." The agent will update the todo list dynamically. ## How to execute the plan Tell the agent to proceed. **Prompt:** `Looks good. Start with the first step.` As the agent works, you'll see the todo list update in real-time above the input box. - **Current focus:** The active task is highlighted (e.g., `[IN_PROGRESS] Create tsconfig.json`). - **Progress:** Completed tasks are marked as done. ## How to monitor progress (`Ctrl+T`) For a long-running task, the full todo list might be hidden to save space. You can toggle the full view at any time. **Action:** Press **Ctrl+T**. This shows the complete list, including pending, in-progress, and completed items. It's a great way to check "how much is left?" without scrolling back up. ## How to handle unexpected changes Plans change. Maybe you discover a library is incompatible halfway through. **Prompt:** `Actually, let's skip the 'server.js' refactor for now. It's too risky.` The agent will mark that task as `cancelled` or remove it, and move to the next item. This dynamic adjustment is what makes the todo system powerful—it's a living document, not a static text block. ## Next steps - Explore [Session management](session-management.md) to save your plan and finish it tomorrow. - See the [Todo tool reference](../../tools/todos.md) for technical schema details. - Learn about [Memory management](memory-management.md) to persist planning preferences (e.g., "Always create a test plan first"). ================================================ FILE: docs/cli/tutorials/web-tools.md ================================================ # Web search and fetch Access the live internet directly from your prompt. In this guide, you'll learn how to search for up-to-date documentation, fetch deep context from specific URLs, and apply that knowledge to your code. ## Prerequisites - Gemini CLI installed and authenticated. - An internet connection. ## How to research new technologies Imagine you want to use a library released yesterday. The model doesn't know about it yet. You need to teach it. ### Scenario: Find documentation **Prompt:** `Search for the 'Bun 1.0' release notes and summarize the key changes.` Gemini uses the `google_web_search` tool to find relevant pages and synthesizes an answer. This "grounding" process ensures the agent isn't hallucinating features that don't exist. **Prompt:** `Find the documentation for the 'React Router v7' loader API.` ## How to fetch deep context Search gives you a summary, but sometimes you need the raw details. The `web_fetch` tool lets you feed a specific URL directly into the agent's context. ### Scenario: Reading a blog post You found a blog post with the exact solution to your bug. **Prompt:** `Read https://example.com/fixing-memory-leaks and explain how to apply it to my code.` Gemini will retrieve the page content (stripping away ads and navigation) and use it to answer your question. ### Scenario: Comparing sources You can even fetch multiple pages to compare approaches. **Prompt:** `Compare the pagination patterns in https://api.example.com/v1/docs and https://api.example.com/v2/docs.` ## How to apply knowledge to code The real power comes when you combine web tools with file editing. **Workflow:** 1. **Search:** "How do I implement auth with Supabase?" 2. **Fetch:** "Read this guide: https://supabase.com/docs/guides/auth." 3. **Implement:** "Great. Now use that pattern to create an `auth.ts` file in my project." ## How to troubleshoot errors When you hit an obscure error message, paste it into the chat. **Prompt:** `I'm getting 'Error: hydration mismatch' in Next.js. Search for recent solutions.` The agent will search sources such as GitHub issues, StackOverflow, and forums to find relevant fixes that might be too new to be in its base training set. ## Next steps - Explore [File management](file-management.md) to see how to apply the code you generate. - See the [Web search tool reference](../../tools/web-search.md) for citation details. - See the [Web fetch tool reference](../../tools/web-fetch.md) for technical limitations. ================================================ FILE: docs/core/index.md ================================================ # Gemini CLI core Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI, handling communication with the Gemini API, managing tools, and processing requests sent from `packages/cli`. For a general overview of Gemini CLI, see the [main documentation page](../index.md). ## Navigating this section - **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use specialized sub-agents for complex tasks. - **[Core tools reference](../reference/tools.md):** Information on how tools are defined, registered, and used by the core. - **[Memory Import Processor](../reference/memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax. - **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for fine-grained control over tool execution. - **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how to enable use of a local Gemma model for model routing decisions. ## Role of the core While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for: - **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses. - **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files. - **Tool management & orchestration:** - Registering available tools (e.g., file system tools, shell command execution). - Interpreting tool use requests from the Gemini model. - Executing the requested tools with the provided arguments. - Returning tool execution results to the Gemini model for further processing. - **Session and state management:** Keeping track of the conversation state, including history and any relevant context required for coherent interactions. - **Configuration:** Managing core-specific configurations, such as API key access, model selection, and tool settings. ## Security considerations The core plays a vital role in security: - **API key management:** It handles the `GEMINI_API_KEY` and ensures it's used securely when communicating with the Gemini API. - **Tool execution:** When tools interact with the local system (e.g., `run_shell_command`), the core (and its underlying tool implementations) must do so with appropriate caution, often involving sandboxing mechanisms to prevent unintended modifications. ## Chat history compression To ensure that long conversations don't exceed the token limits of the Gemini model, the core includes a chat history compression feature. When a conversation approaches the token limit for the configured model, the core automatically compresses the conversation history before sending it to the model. This compression is designed to be lossless in terms of the information conveyed, but it reduces the overall number of tokens used. You can find the token limits for each model in the [Google AI documentation](https://ai.google.dev/gemini-api/docs/models). ## Model fallback Gemini CLI includes a model fallback mechanism to ensure that you can continue to use the CLI even if the default "pro" model is rate-limited. If you are using the default "pro" model and the CLI detects that you are being rate-limited, it automatically switches to the "flash" model for the current session. This allows you to continue working without interruption. Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt completion and classification) silently fall back to `gemini-2.5-flash` and `gemini-2.5-pro` when quota is exhausted, without changing the configured model. ## File discovery service The file discovery service is responsible for finding files in the project that are relevant to the current context. It is used by the `@` command and other tools that need to access files. ## Memory discovery service The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories. This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information. You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files. ## Citations When Gemini finds it is reciting text from a source it appends the citation to the output. It is enabled by default but can be disabled with the ui.showCitations setting. - When proposing an edit the citations display before giving the user the option to accept. - Citations are always shown at the end of the model’s turn. - We deduplicate citations and display them in alphabetical order. ================================================ FILE: docs/core/local-model-routing.md ================================================ # Local Model Routing (experimental) Gemini CLI supports using a local model for [routing decisions](../cli/model-routing.md). When configured, Gemini CLI will use a locally-running **Gemma** model to make routing decisions (instead of sending routing decisions to a hosted model). This feature can help reduce costs associated with hosted model usage while offering similar routing decision latency and quality. > **Note: Local model routing is currently an experimental feature.** ## Setup Using a Gemma model for routing decisions requires that an implementation of a Gemma model be running locally on your machine, served behind an HTTP endpoint and accessed via the Gemini API. To serve the Gemma model, follow these steps: ### Download the LiteRT-LM runtime The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers pre-built binaries for locally-serving models. Download the binary appropriate for your system. #### Windows 1. Download [lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe). 2. Using GPU on Windows requires the DirectXShaderCompiler. Download the [dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip). Unzip the archive and from the architecture-appropriate `bin\` directory, and copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved `lit.windows_x86_64.exe`. 3. (Optional) Test starting the runtime: `.\lit.windows_x86_64.exe serve --verbose` #### Linux 1. Download [lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64). 2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64` 3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose` #### MacOS 1. Download [lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64). 2. Ensure the binary is executable: `chmod a+x lit.macos_arm64` 3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose` > **Note**: MacOS can be configured to only allows binaries from "App Store & > Known Developers". If you encounter an error message when attempting to run > the binary, you will need to allow the application. One option is to visit > `System Settings -> Privacy & Security`, scroll to `Security`, and click > `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run > `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline. ### Download the Gemma Model Before using Gemma, you will need to download the model (and agree to the Terms of Service). This can be done via the LiteRT-LM runtime. #### Windows ```bash $ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom [Legal] The model you are about to download is governed by the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. Full Terms: https://ai.google.dev/gemma/terms Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy Do you accept these terms? (Y/N): Y Terms accepted. Downloading model 'gemma3-1b-gpu-custom' ... Downloading... 968.6 MB Download complete. ``` #### Linux ```bash $ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom [Legal] The model you are about to download is governed by the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. Full Terms: https://ai.google.dev/gemma/terms Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy Do you accept these terms? (Y/N): Y Terms accepted. Downloading model 'gemma3-1b-gpu-custom' ... Downloading... 968.6 MB Download complete. ``` #### MacOS ```bash $ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom [Legal] The model you are about to download is governed by the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. Full Terms: https://ai.google.dev/gemma/terms Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy Do you accept these terms? (Y/N): Y Terms accepted. Downloading model 'gemma3-1b-gpu-custom' ... Downloading... 968.6 MB Download complete. ``` ### Start LiteRT-LM Runtime Using the command appropriate to your system, start the LiteRT-LM runtime. Configure the port that you want to use for your Gemma model. For the purposes of this document, we will use port `9379`. Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose` ### (Optional) Verify Model Serving Send a quick prompt to the model via HTTP to validate successful model serving. This will cause the runtime to download the model and run it once. You should see a short joke in the server output as an indicator of success. #### Windows ``` # Run this in PowerShell to send a request to the server $uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" $body = @{contents = @( @{ role = "user" parts = @( @{ text = "Tell me a joke." } ) })} | ConvertTo-Json -Depth 10 Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json" ``` #### Linux/MacOS ```bash $ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \ -H 'Content-Type: application/json' \ -X POST \ -d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}' ``` ## Configuration To use a local Gemma model for routing, you must explicitly enable it in your `settings.json`: ```json { "experimental": { "gemmaModelRouter": { "enabled": true, "classifier": { "host": "http://localhost:9379", "model": "gemma3-1b-gpu-custom" } } } } ``` > Use the port you started your LiteRT-LM runtime on in the setup steps. ### Configuration schema | Field | Type | Required | Description | | :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- | | `enabled` | boolean | Yes | Must be `true` to enable the feature. | | `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. | | `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:`. | | `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. | > **Note: You will need to restart after configuration changes for local model > routing to take effect.** ================================================ FILE: docs/core/remote-agents.md ================================================ # Remote Subagents (experimental) Gemini CLI supports connecting to remote subagents using the Agent-to-Agent (A2A) protocol. This allows Gemini CLI to interact with other agents, expanding its capabilities by delegating tasks to remote services. Gemini CLI can connect to any compliant A2A agent. You can find samples of A2A agents in the following repositories: - [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) - [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) > [!NOTE] > Remote subagents are currently an experimental feature. ## Configuration To use remote subagents, you must explicitly enable them in your `settings.json`: ```json { "experimental": { "enableAgents": true } } ``` ## Proxy support Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is configured. It uses the `general.proxy` setting in your `settings.json` file or standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`). ```json { "general": { "proxy": "http://my-proxy:8080" } } ``` ## Defining remote subagents Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter. You can place them in: 1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) 2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) ### Configuration schema | Field | Type | Required | Description | | :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- | | `kind` | string | Yes | Must be `remote`. | | `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | | `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. | | `auth` | object | No | Authentication configuration. See [Authentication](#authentication). | ### Single-subagent example ```markdown --- kind: remote name: my-remote-agent agent_card_url: https://example.com/agent-card --- ``` ### Multi-subagent example The loader explicitly supports multiple remote subagents defined in a single Markdown file. ```markdown --- - kind: remote name: remote-1 agent_card_url: https://example.com/1 - kind: remote name: remote-2 agent_card_url: https://example.com/2 --- ``` > [!NOTE] Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. ## Authentication Many remote agents require authentication. Gemini CLI supports several authentication methods aligned with the [A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme). Add an `auth` block to your agent's frontmatter to configure credentials. ### Supported auth types Gemini CLI supports the following authentication types: | Type | Description | | :------------------- | :--------------------------------------------------------------------------------------------- | | `apiKey` | Send a static API key as an HTTP header. | | `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). | | `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. | | `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. | ### Dynamic values For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`, `password`, `value`) support dynamic resolution: | Format | Description | Example | | :---------- | :-------------------------------------------------- | :------------------------- | | `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` | | `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` | | literal | Use the string as-is. | `sk-abc123` | | `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` | > **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets > directly in agent files, especially for project-level agents checked into > version control. ### API key (`apiKey`) Sends an API key as an HTTP header on every request. | Field | Type | Required | Description | | :----- | :----- | :------- | :---------------------------------------------------- | | `type` | string | Yes | Must be `apiKey`. | | `key` | string | Yes | The API key value. Supports dynamic values. | | `name` | string | No | Header name to send the key in. Default: `X-API-Key`. | ```yaml --- kind: remote name: my-agent agent_card_url: https://example.com/agent-card auth: type: apiKey key: $MY_API_KEY --- ``` ### HTTP authentication (`http`) Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP authentication schemes. #### Bearer token Use the following fields to configure a Bearer token: | Field | Type | Required | Description | | :------- | :----- | :------- | :----------------------------------------- | | `type` | string | Yes | Must be `http`. | | `scheme` | string | Yes | Must be `Bearer`. | | `token` | string | Yes | The bearer token. Supports dynamic values. | ```yaml auth: type: http scheme: Bearer token: $MY_BEARER_TOKEN ``` #### Basic authentication Use the following fields to configure Basic authentication: | Field | Type | Required | Description | | :--------- | :----- | :------- | :------------------------------------- | | `type` | string | Yes | Must be `http`. | | `scheme` | string | Yes | Must be `Basic`. | | `username` | string | Yes | The username. Supports dynamic values. | | `password` | string | Yes | The password. Supports dynamic values. | ```yaml auth: type: http scheme: Basic username: $MY_USERNAME password: $MY_PASSWORD ``` #### Raw scheme For any other IANA-registered scheme (for example, Digest, HOBA), provide the raw authorization value. | Field | Type | Required | Description | | :------- | :----- | :------- | :---------------------------------------------------------------------------- | | `type` | string | Yes | Must be `http`. | | `scheme` | string | Yes | The scheme name (for example, `Digest`). | | `value` | string | Yes | Raw value sent as `Authorization: `. Supports dynamic values. | ```yaml auth: type: http scheme: Digest value: $MY_DIGEST_VALUE ``` ### Google Application Default Credentials (`google-credentials`) Uses [Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials) to authenticate with Google Cloud services and Cloud Run endpoints. This is the recommended auth method for agents hosted on Google Cloud infrastructure. | Field | Type | Required | Description | | :------- | :------- | :------- | :-------------------------------------------------------------------------- | | `type` | string | Yes | Must be `google-credentials`. | | `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. | ```yaml --- kind: remote name: my-gcp-agent agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json auth: type: google-credentials --- ``` #### How token selection works The provider automatically selects the correct token type based on the agent's host: | Host pattern | Token type | Use case | | :----------------- | :----------------- | :------------------------------------------ | | `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) | | `*.run.app` | **Identity token** | Cloud Run services | - **Access tokens** authorize API calls to Google services. They are scoped (default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`. - **Identity tokens** prove the caller's identity to a service that validates the token's audience. The audience is set to the target host. These are fetched via `GoogleAuth.getIdTokenClient()`. Both token types are cached and automatically refreshed before expiry. #### Setup `google-credentials` relies on ADC, which means your environment must have credentials configured. Common setups: - **Local development:** Run `gcloud auth application-default login` to authenticate with your Google account. - **CI / Cloud environments:** Use a service account. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your service account key file, or use workload identity on GKE / Cloud Run. #### Allowed hosts For security, `google-credentials` only sends tokens to known Google-owned hosts: - `*.googleapis.com` - `*.run.app` Requests to any other host will be rejected with an error. If your agent is hosted on a different domain, use one of the other auth types (`apiKey`, `http`, or `oauth2`). #### Examples The following examples demonstrate how to configure Google Application Default Credentials. **Cloud Run agent:** ```yaml --- kind: remote name: cloud-run-agent agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json auth: type: google-credentials --- ``` **Google API with custom scopes:** ```yaml --- kind: remote name: vertex-agent agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json auth: type: google-credentials scopes: - https://www.googleapis.com/auth/cloud-platform - https://www.googleapis.com/auth/compute --- ``` ### OAuth 2.0 (`oauth2`) Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first use, Gemini CLI opens your browser for sign-in and persists the resulting tokens for subsequent requests. | Field | Type | Required | Description | | :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | Yes | Must be `oauth2`. | | `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. | | `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. | | `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. | | `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. | | `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. | ```yaml --- kind: remote name: oauth-agent agent_card_url: https://example.com/.well-known/agent.json auth: type: oauth2 client_id: my-client-id.apps.example.com --- ``` If the agent card advertises an `oauth2` security scheme with `authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are automatically discovered. You only need to provide `client_id` (and `client_secret` if required). Tokens are persisted to disk and refreshed automatically when they expire. ### Auth validation When Gemini CLI loads a remote agent, it validates your auth configuration against the agent card's declared `securitySchemes`. If the agent requires authentication that you haven't configured, you'll see an error describing what's needed. `google-credentials` is treated as compatible with `http` Bearer security schemes, since it produces Bearer tokens. ### Auth retry behavior All auth providers automatically retry on `401` and `403` responses by re-fetching credentials (up to 2 retries). This handles cases like expired tokens or rotated credentials. For `apiKey` with `!command` values, the command is re-executed on retry to fetch a fresh key. ### Agent card fetching and auth When connecting to a remote agent, Gemini CLI first fetches the agent card **without** authentication. If the card endpoint returns a `401` or `403`, it retries the fetch **with** the configured auth headers. This lets agents have publicly accessible cards while protecting their task endpoints, or to protect both behind auth. ## Managing Subagents Users can manage subagents using the following commands within the Gemini CLI: - `/agents list`: Displays all available local and remote subagents. - `/agents reload`: Reloads the agent registry. Use this after adding or modifying agent definition files. - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. > [!TIP] > You can use the `@cli_help` agent within Gemini CLI for assistance > with configuring subagents. ================================================ FILE: docs/core/subagents.md ================================================ # Subagents (experimental) Subagents are specialized agents that operate within your main Gemini CLI session. They are designed to handle specific, complex tasks—like deep codebase analysis, documentation lookup, or domain-specific reasoning—without cluttering the main agent's context or toolset. > [!NOTE] > Subagents are currently an experimental feature. > To use custom subagents, you must ensure they are enabled in your `settings.json` (enabled by default): ```json { "experimental": { "enableAgents": true } } ``` ## What are subagents? Subagents are "specialists" that the main Gemini agent can hire for a specific job. - **Focused context:** Each subagent has its own system prompt and persona. - **Specialized tools:** Subagents can have a restricted or specialized set of tools. - **Independent context window:** Interactions with a subagent happen in a separate context loop, which saves tokens in your main conversation history. Subagents are exposed to the main agent as a tool of the same name. When the main agent calls the tool, it delegates the task to the subagent. Once the subagent completes its task, it reports back to the main agent with its findings. ## How to use subagents You can use subagents through automatic delegation or by explicitly forcing them in your prompt. ### Automatic delegation Gemini CLI's main agent is instructed to use specialized subagents when a task matches their expertise. For example, if you ask "How does the auth system work?", the main agent may decide to call the `codebase_investigator` subagent to perform the research. ### Forcing a subagent (@ syntax) You can explicitly direct a task to a specific subagent by using the `@` symbol followed by the subagent's name at the beginning of your prompt. This is useful when you want to bypass the main agent's decision-making and go straight to a specialist. **Example:** ```bash @codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor. ``` When you use the `@` syntax, the CLI injects a system note that nudges the primary model to use that specific subagent tool immediately. ## Built-in subagents Gemini CLI comes with the following built-in subagents: ### Codebase Investigator - **Name:** `codebase_investigator` - **Purpose:** Analyze the codebase, reverse engineer, and understand complex dependencies. - **When to use:** "How does the authentication system work?", "Map out the dependencies of the `AgentRegistry` class." - **Configuration:** Enabled by default. You can override its settings in `settings.json` under `agents.overrides`. Example (forcing a specific model and increasing turns): ```json { "agents": { "overrides": { "codebase_investigator": { "modelConfig": { "model": "gemini-3-flash-preview" }, "runConfig": { "maxTurns": 50 } } } } } ``` ### CLI Help Agent - **Name:** `cli_help` - **Purpose:** Get expert knowledge about Gemini CLI itself, its commands, configuration, and documentation. - **When to use:** "How do I configure a proxy?", "What does the `/rewind` command do?" - **Configuration:** Enabled by default. ### Generalist Agent - **Name:** `generalist_agent` - **Purpose:** Route tasks to the appropriate specialized subagent. - **When to use:** Implicitly used by the main agent for routing. Not directly invoked by the user. - **Configuration:** Enabled by default. No specific configuration options. ### Browser Agent (experimental) - **Name:** `browser_agent` - **Purpose:** Automate web browser tasks — navigating websites, filling forms, clicking buttons, and extracting information from web pages — using the accessibility tree. - **When to use:** "Go to example.com and fill out the contact form," "Extract the pricing table from this page," "Click the login button and enter my credentials." > [!NOTE] > This is a preview feature currently under active development. #### Prerequisites The browser agent requires: - **Chrome** version 144 or later (any recent stable release will work). - **Node.js** with `npx` available (used to launch the [`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp) server). #### Enabling the browser agent The browser agent is disabled by default. Enable it in your `settings.json`: ```json { "agents": { "overrides": { "browser_agent": { "enabled": true } } } } ``` #### Session modes The `sessionMode` setting controls how Chrome is launched and managed. Set it under `agents.browser`: ```json { "agents": { "overrides": { "browser_agent": { "enabled": true } }, "browser": { "sessionMode": "persistent" } } } ``` The available modes are: | Mode | Description | | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. | | `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. | | `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. | #### Configuration reference All browser-specific settings go under `agents.browser` in your `settings.json`. | Setting | Type | Default | Description | | :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- | | `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. | | `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). | | `profilePath` | `string` | — | Custom path to a browser profile directory. | | `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). | #### Security The browser agent enforces the following security restrictions: - **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`, `chrome://extensions`, and `chrome://settings/passwords` are always blocked. - **Sensitive action confirmation:** Actions like form filling, file uploads, and form submissions require user confirmation through the standard policy engine. #### Visual agent By default, the browser agent interacts with pages through the accessibility tree using element `uid` values. For tasks that require visual identification (for example, "click the yellow button" or "find the red error message"), you can enable the visual agent by setting a `visualModel`: ```json { "agents": { "overrides": { "browser_agent": { "enabled": true } }, "browser": { "visualModel": "gemini-2.5-computer-use-preview-10-2025" } } } ``` When enabled, the agent gains access to the `analyze_screenshot` tool, which captures a screenshot and sends it to the vision model for analysis. The model returns coordinates and element descriptions that the browser agent uses with the `click_at` tool for precise, coordinate-based interactions. > [!NOTE] > The visual agent requires API key or Vertex AI authentication. It is > not available when using "Sign in with Google". ## Creating custom subagents You can create your own subagents to automate specific workflows or enforce specific personas. To use custom subagents, you must enable them in your `settings.json`: ```json { "experimental": { "enableAgents": true } } ``` ### Agent definition files Custom agents are defined as Markdown files (`.md`) with YAML frontmatter. You can place them in: 1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) 2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) ### File format The file **MUST** start with YAML frontmatter enclosed in triple-dashes `---`. The body of the markdown file becomes the agent's **System Prompt**. **Example: `.gemini/agents/security-auditor.md`** ```markdown --- name: security-auditor description: Specialized in finding security vulnerabilities in code. kind: local tools: - read_file - grep_search model: gemini-3-flash-preview temperature: 0.2 max_turns: 10 --- You are a ruthless Security Auditor. Your job is to analyze code for potential vulnerabilities. Focus on: 1. SQL Injection 2. XSS (Cross-Site Scripting) 3. Hardcoded credentials 4. Unsafe file operations When you find a vulnerability, explain it clearly and suggest a fix. Do not fix it yourself; just report it. ``` ### Configuration schema | Field | Type | Required | Description | | :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. | | `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | | `kind` | string | No | `local` (default) or `remote`. | | `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | | `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | | `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | | `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | | `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. | ### Tool wildcards When defining `tools` for a subagent, you can use wildcards to quickly grant access to groups of tools: - `*`: Grant access to all available built-in and discovered tools. - `mcp_*`: Grant access to all tools from all connected MCP servers. - `mcp_my-server_*`: Grant access to all tools from a specific MCP server named `my-server`. ### Isolation and recursion protection Each subagent runs in its own isolated context loop. This means: - **Independent history:** The subagent's conversation history does not bloat the main agent's context. - **Isolated tools:** The subagent only has access to the tools you explicitly grant it. - **Recursion protection:** To prevent infinite loops and excessive token usage, subagents **cannot** call other subagents. If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke other agents. ## Managing subagents You can manage subagents interactively using the `/agents` command or persistently via `settings.json`. ### Interactive management (/agents) If you are in an interactive CLI session, you can use the `/agents` command to manage subagents without editing configuration files manually. This is the recommended way to quickly enable, disable, or re-configure agents on the fly. For a full list of sub-commands and usage, see the [`/agents` command reference](../reference/commands.md#agents). ### Persistent configuration (settings.json) While the `/agents` command and agent definition files provide a starting point, you can use `settings.json` for global, persistent overrides. This is useful for enforcing specific models or execution limits across all sessions. #### `agents.overrides` Use this to enable or disable specific agents or override their run configurations. ```json { "agents": { "overrides": { "security-auditor": { "enabled": false, "runConfig": { "maxTurns": 20, "maxTimeMinutes": 10 } } } } } ``` #### `modelConfigs.overrides` You can target specific subagents with custom model settings (like system instruction prefixes or specific safety settings) using the `overrideScope` field. ```json { "modelConfigs": { "overrides": [ { "match": { "overrideScope": "security-auditor" }, "modelConfig": { "generateContentConfig": { "temperature": 0.1 } } } ] } } ``` ### Optimizing your subagent The main agent's system prompt encourages it to use an expert subagent when one is available. It decides whether an agent is a relevant expert based on the agent's description. You can improve the reliability with which an agent is used by updating the description to more clearly indicate: - Its area of expertise. - When it should be used. - Some example scenarios. For example, the following subagent description should be called fairly consistently for Git operations. > Git expert agent which should be used for all local and remote git operations. > For example: > > - Making commits > - Searching for regressions with bisect > - Interacting with source control and issues providers such as GitHub. If you need to further tune your subagent, you can do so by selecting the model to optimize for with `/model` and then asking the model why it does not think that your subagent was called with a specific prompt and the given description. ## Remote subagents (Agent2Agent) (experimental) Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent (A2A) protocol. > [!NOTE] > Remote subagents are currently an experimental feature. See the [Remote Subagents documentation](remote-agents) for detailed configuration, authentication, and usage instructions. ## Extension subagents Extensions can bundle and distribute subagents. See the [Extensions documentation](../extensions/index.md#subagents) for details on how to package agents within an extension. ================================================ FILE: docs/examples/proxy-script.md ================================================ # Example proxy script The following is an example of a proxy script that can be used with the `GEMINI_SANDBOX_PROXY_COMMAND` environment variable. This script only allows `HTTPS` connections to `example.com:443` and declines all other requests. ```javascript #!/usr/bin/env node /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // Example proxy server that listens on :::8877 and only allows HTTPS connections to example.com. // Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox // Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool) import http from 'node:http'; import net from 'node:net'; import { URL } from 'node:url'; import console from 'node:console'; const PROXY_PORT = 8877; const ALLOWED_DOMAINS = ['example.com', 'googleapis.com']; const ALLOWED_PORT = '443'; const server = http.createServer((req, res) => { // Deny all requests other than CONNECT for HTTPS console.log( `[PROXY] Denying non-CONNECT request for: ${req.method} ${req.url}`, ); res.writeHead(405, { 'Content-Type': 'text/plain' }); res.end('Method Not Allowed'); }); server.on('connect', (req, clientSocket, head) => { // req.url will be in the format "hostname:port" for a CONNECT request. const { port, hostname } = new URL(`http://${req.url}`); console.log(`[PROXY] Intercepted CONNECT request for: ${hostname}:${port}`); if ( ALLOWED_DOMAINS.some( (domain) => hostname == domain || hostname.endsWith(`.${domain}`), ) && port === ALLOWED_PORT ) { console.log(`[PROXY] Allowing connection to ${hostname}:${port}`); // Establish a TCP connection to the original destination. const serverSocket = net.connect(port, hostname, () => { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); // Create a tunnel by piping data between the client and the destination server. serverSocket.write(head); serverSocket.pipe(clientSocket); clientSocket.pipe(serverSocket); }); serverSocket.on('error', (err) => { console.error(`[PROXY] Error connecting to destination: ${err.message}`); clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n\r\n`); }); } else { console.log(`[PROXY] Denying connection to ${hostname}:${port}`); clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); } clientSocket.on('error', (err) => { // This can happen if the client hangs up. console.error(`[PROXY] Client socket error: ${err.message}`); }); }); server.listen(PROXY_PORT, () => { const address = server.address(); console.log(`[PROXY] Proxy listening on ${address.address}:${address.port}`); console.log( `[PROXY] Allowing HTTPS connections to domains: ${ALLOWED_DOMAINS.join(', ')}`, ); }); ``` ================================================ FILE: docs/extensions/best-practices.md ================================================ # Gemini CLI extension best practices This guide covers best practices for developing, securing, and maintaining Gemini CLI extensions. ## Development Developing extensions for Gemini CLI is a lightweight, iterative process. Use these strategies to build robust and efficient extensions. ### Structure your extension While simple extensions may contain only a few files, we recommend a organized structure for complex projects. ```text my-extension/ ├── package.json ├── tsconfig.json ├── gemini-extension.json ├── src/ │ ├── index.ts │ └── tools/ └── dist/ ``` - **Use TypeScript:** We strongly recommend using TypeScript for type safety and improved developer experience. - **Separate source and build:** Keep your source code in `src/` and output build artifacts to `dist/`. - **Bundle dependencies:** If your extension has many dependencies, bundle them using a tool like `esbuild` to reduce installation time and avoid conflicts. ### Iterate with `link` Use the `gemini extensions link` command to develop locally without reinstalling your extension after every change. ```bash cd my-extension gemini extensions link . ``` Changes to your code are immediately available in the CLI after you rebuild the project and restart the session. ### Use `GEMINI.md` effectively Your `GEMINI.md` file provides essential context to the model. - **Focus on goals:** Explain the high-level purpose of the extension and how to interact with its tools. - **Be concise:** Avoid dumping exhaustive documentation into the file. Use clear, direct language. - **Provide examples:** Include brief examples of how the model should use specific tools or commands. ## Security Follow the principle of least privilege and rigorous input validation when building extensions. ### Minimal permissions Only request the permissions your MCP server needs to function. Avoid giving the model broad access (such as full shell access) if restricted tools are sufficient. If your extension uses powerful tools like `run_shell_command`, restrict them in your `gemini-extension.json` file: ```json { "name": "my-safe-extension", "excludeTools": ["run_shell_command(rm -rf *)"] } ``` This ensures the CLI blocks dangerous commands even if the model attempts to execute them. ### Validate inputs Your MCP server runs on the user's machine. Always validate tool inputs to prevent arbitrary code execution or unauthorized filesystem access. ```typescript // Example: Validating paths if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) { throw new Error('Access denied'); } ``` ### Secure sensitive settings If your extension requires API keys or other secrets, use the `sensitive: true` option in your manifest. This ensures keys are stored in the system keychain and obfuscated in the CLI output. ```json "settings": [ { "name": "API Key", "envVar": "MY_API_KEY", "sensitive": true } ] ``` ## Release Follow standard versioning and release practices to ensure a smooth experience for your users. ### Semantic versioning Follow [Semantic Versioning (SemVer)](https://semver.org/) to communicate changes clearly. - **Major:** Breaking changes (e.g., renaming tools or changing arguments). - **Minor:** New features (e.g., adding new tools or commands). - **Patch:** Bug fixes and performance improvements. ### Release channels Use Git branches to manage release channels. This lets users choose between stability and the latest features. ```bash # Install the stable version (default branch) gemini extensions install github.com/user/repo # Install the development version gemini extensions install github.com/user/repo --ref dev ``` ### Clean artifacts When using GitHub Releases, ensure your archives only contain necessary files (such as `dist/`, `gemini-extension.json`, and `package.json`). Exclude `node_modules/` and `src/` to minimize download size. ## Test and verify Test your extension thoroughly before releasing it to users. - **Manual verification:** Use `gemini extensions link` to test your extension in a live CLI session. Verify that tools appear in the debug console (F12) and that custom commands resolve correctly. - **Automated testing:** If your extension includes an MCP server, write unit tests for your tool logic using a framework like Vitest or Jest. You can test MCP tools in isolation by mocking the transport layer. ## Troubleshooting Use these tips to diagnose and fix common extension issues. ### Extension not loading If your extension doesn't appear in `/extensions list`: - **Check the manifest:** Ensure `gemini-extension.json` is in the root directory and contains valid JSON. - **Verify the name:** The `name` field in the manifest must match the extension directory name exactly. - **Restart the CLI:** Extensions are loaded at the start of a session. Restart Gemini CLI after making changes to the manifest or linking a new extension. ### MCP server failures If your tools aren't working as expected: - **Check the logs:** View the CLI logs to see if the MCP server failed to start. - **Test the command:** Run the server's `command` and `args` directly in your terminal to ensure it starts correctly outside of Gemini CLI. - **Debug console:** In interactive mode, press **F12** to open the debug console and inspect tool calls and responses. ### Command conflicts If a custom command isn't responding: - **Check precedence:** Remember that user and project commands take precedence over extension commands. Use the prefixed name (e.g., `/extension.command`) to verify the extension's version. - **Help command:** Run `/help` to see a list of all available commands and their sources. ================================================ FILE: docs/extensions/index.md ================================================ # Gemini CLI extensions Gemini CLI extensions package prompts, MCP servers, custom commands, themes, hooks, sub-agents, and agent skills into a familiar and user-friendly format. With extensions, you can expand the capabilities of Gemini CLI and share those capabilities with others. They are designed to be easily installable and shareable. To see what's possible, browse the [Gemini CLI extension gallery](https://geminicli.com/extensions/browse/). ## Choose your path Choose the guide that best fits your needs. ### I want to use extensions Learn how to discover, install, and manage extensions to enhance your Gemini CLI experience. - **[Manage extensions](#manage-extensions):** List and verify your installed extensions. - **[Install extensions](#installation):** Add new capabilities from GitHub or local paths. ### I want to build extensions Learn how to create, test, and share your own extensions with the community. - **[Build extensions](writing-extensions.md):** Create your first extension from a template. - **[Best practices](best-practices.md):** Learn how to build secure and reliable extensions. - **[Publish to the gallery](releasing.md):** Share your work with the world. ## Manage extensions Use the interactive `/extensions` command to verify your installed extensions and their status: ```bash /extensions list ``` You can also manage extensions from your terminal using the `gemini extensions` command group: ```bash gemini extensions list ``` ## Installation Install an extension by providing its GitHub repository URL. For example: ```bash gemini extensions install https://github.com/gemini-cli-extensions/workspace ``` For more advanced installation options, see the [Extension reference](reference.md#install-an-extension). ================================================ FILE: docs/extensions/reference.md ================================================ # Extension reference This guide covers the `gemini extensions` commands and the structure of the `gemini-extension.json` configuration file. ## Manage extensions Use the `gemini extensions` command group to manage your extensions from the terminal. Note that commands like `gemini extensions install` are not supported within the CLI's interactive mode. However, you can use the `/extensions list` command to view installed extensions. All management operations, including updates to slash commands, take effect only after you restart the CLI session. ### Install an extension Install an extension by providing its GitHub repository URL or a local file path. Gemini CLI creates a copy of the extension during installation. You must run `gemini extensions update` to pull changes from the source. To install from GitHub, you must have `git` installed on your machine. ```bash gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] ``` - ``: The GitHub URL or local path of the extension. - `--ref`: The git ref (branch, tag, or commit) to install. - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. ### Uninstall an extension To uninstall one or more extensions, use the `uninstall` command: ```bash gemini extensions uninstall ``` ### Disable an extension Extensions are enabled globally by default. You can disable an extension entirely or for a specific workspace. ```bash gemini extensions disable [--scope ] ``` - ``: The name of the extension to disable. - `--scope`: The scope to disable the extension in (`user` or `workspace`). ### Enable an extension Re-enable a disabled extension using the `enable` command: ```bash gemini extensions enable [--scope ] ``` - ``: The name of the extension to enable. - `--scope`: The scope to enable the extension in (`user` or `workspace`). ### Update an extension Update an extension to the version specified in its `gemini-extension.json` file. ```bash gemini extensions update ``` To update all installed extensions at once: ```bash gemini extensions update --all ``` ### Create an extension from a template Create a new extension directory using a built-in template. ```bash gemini extensions new [template] ``` - ``: The directory to create. - `[template]`: The template to use (e.g., `mcp-server`, `context`, `custom-commands`). ### Link a local extension Create a symbolic link between your development directory and the Gemini CLI extensions directory. This lets you test changes immediately without reinstalling. ```bash gemini extensions link ``` ## Extension format Gemini CLI loads extensions from `/.gemini/extensions`. Each extension must have a `gemini-extension.json` file in its root directory. ### `gemini-extension.json` The manifest file defines the extension's behavior and configuration. ```json { "name": "my-extension", "version": "1.0.0", "description": "My awesome extension", "mcpServers": { "my-server": { "command": "node", "args": ["${extensionPath}/my-server.js"], "cwd": "${extensionPath}" } }, "contextFileName": "GEMINI.md", "excludeTools": ["run_shell_command"], "migratedTo": "https://github.com/new-owner/new-extension-repo", "plan": { "directory": ".gemini/plans" } } ``` - `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name. - `version`: The version of the extension. - `description`: A short description of the extension. This will be displayed on [geminicli.com/extensions](https://geminicli.com/extensions). - `migratedTo`: The URL of the new repository source for the extension. If this is set, the CLI will automatically check this new source for updates and migrate the extension's installation to the new source if an update is found. - `mcpServers`: A map of MCP servers to settings. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers defined in a [`settings.json` file](../reference/configuration.md). If both an extension and a `settings.json` file define an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. - For portability, you should use `${extensionPath}` to refer to files within your extension directory. - Separate your executable and its arguments using `command` and `args` instead of putting them both in `command`. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded. - `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. - `plan`: Planning features configuration. - `directory`: The directory where planning artifacts are stored. This serves as a fallback if the user hasn't specified a plan directory in their settings. If not specified by either the extension or the user, the default is `~/.gemini/tmp///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. ### Extension settings Extensions can define settings that users provide during installation, such as API keys or URLs. These values are stored in a `.env` file within the extension directory. To define settings, add a `settings` array to your manifest: ```json { "name": "my-api-extension", "version": "1.0.0", "settings": [ { "name": "API Key", "description": "Your API key for the service.", "envVar": "MY_API_KEY", "sensitive": true } ] } ``` - `name`: The setting's display name. - `description`: A clear explanation of the setting. - `envVar`: The environment variable name where the value is stored. - `sensitive`: If `true`, the value is stored in the system keychain and obfuscated in the UI. To update an extension's settings: ```bash gemini extensions config [setting] [--scope ] ``` ### Custom commands Provide [custom commands](../cli/custom-commands.md) by placing TOML files in a `commands/` subdirectory. Gemini CLI uses the directory structure to determine the command name. For an extension named `gcp`: - `commands/deploy.toml` becomes `/deploy` - `commands/gcs/sync.toml` becomes `/gcs:sync` (namespaced with a colon) ### Hooks Intercept and customize CLI behavior using [hooks](../hooks/index.md). Define hooks in a `hooks/hooks.json` file within your extension directory. Note that hooks are not defined in the `gemini-extension.json` manifest. ### Agent skills Bundle [agent skills](../cli/skills.md) to provide specialized workflows. Place skill definitions in a `skills/` directory. For example, `skills/security-audit/SKILL.md` exposes a `security-audit` skill. ### Sub-agents > [!NOTE] > Sub-agents are a preview feature currently under active development. Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. ### Policy Engine Extensions can contribute policy rules and safety checkers to the Gemini CLI [Policy Engine](../reference/policy-engine.md). These rules are defined in `.toml` files and take effect when the extension is activated. To add policies, create a `policies/` directory in your extension's root and place your `.toml` policy files inside it. Gemini CLI automatically loads all `.toml` files from this directory. Rules contributed by extensions run in their own tier (tier 2), alongside workspace-defined policies. This tier has higher priority than the default rules but lower priority than user or admin policies. > [!WARNING] > For security, Gemini CLI ignores any `allow` decisions or `yolo` > mode configurations in extension policies. This ensures that an extension > cannot automatically approve tool calls or bypass security measures without > your confirmation. **Example `policies.toml`** ```toml [[rule]] mcpName = "my_server" toolName = "dangerous_tool" decision = "ask_user" priority = 100 [[safety_checker]] mcpName = "my_server" toolName = "write_data" priority = 200 [safety_checker.checker] type = "in-process" name = "allowed-path" required_context = ["environment"] ``` ### Themes Extensions can provide custom themes to personalize the CLI UI. Themes are defined in the `themes` array in `gemini-extension.json`. **Example** ```json { "name": "my-green-extension", "version": "1.0.0", "themes": [ { "name": "shades-of-green", "type": "custom", "background": { "primary": "#1a362a" }, "text": { "primary": "#a6e3a1", "secondary": "#6e8e7a", "link": "#89e689" }, "status": { "success": "#76c076", "warning": "#d9e689", "error": "#b34e4e" }, "border": { "default": "#4a6c5a" }, "ui": { "comment": "#6e8e7a" } } ] } ``` Custom themes provided by extensions can be selected using the `/theme` command or by setting the `ui.theme` property in your `settings.json` file. Note that when referring to a theme from an extension, the extension name is appended to the theme name in parentheses, e.g., `shades-of-green (my-green-extension)`. ### Conflict resolution Extension commands have the lowest precedence. If an extension command name conflicts with a user or project command, the extension command is prefixed with the extension name (e.g., `/gcp.deploy`) using a dot separator. ## Variables Gemini CLI supports variable substitution in `gemini-extension.json` and `hooks/hooks.json`. | Variable | Description | | :----------------- | :---------------------------------------------- | | `${extensionPath}` | The absolute path to the extension's directory. | | `${workspacePath}` | The absolute path to the current workspace. | | `${/}` | The platform-specific path separator. | ================================================ FILE: docs/extensions/releasing.md ================================================ # Release extensions Release Gemini CLI extensions to your users through a Git repository or GitHub Releases. Git repository releases are the simplest approach and offer the most flexibility for managing development branches. GitHub Releases are more efficient for initial installations because they ship as single archives rather than requiring a full `git clone`. Use GitHub Releases if you need to include platform-specific binary files. ## List your extension in the gallery The [Gemini CLI extension gallery](https://geminicli.com/extensions/browse/) automatically indexes public extensions to help users discover your work. You don't need to submit an issue or email us to list your extension. To have your extension automatically discovered and listed: 1. **Use a public repository:** Ensure your extension is hosted in a public GitHub repository. 2. **Add the GitHub topic:** Add the `gemini-cli-extension` topic to your repository's **About** section. Our crawler uses this topic to find new extensions. 3. **Place the manifest at the root:** Ensure your `gemini-extension.json` file is in the absolute root of the repository or the release archive. Our system crawls tagged repositories daily. Once you tag your repository, your extension will appear in the gallery if it passes validation. ## Release through a Git repository Releasing through Git is the most flexible option. Create a public Git repository and provide the URL to your users. They can then install your extension using `gemini extensions install `. Users can optionally depend on a specific branch, tag, or commit using the `--ref` argument. For example: ```bash gemini extensions install --ref=stable ``` Whenever you push commits to the referenced branch, the CLI prompts users to update their installation. The `HEAD` commit is always treated as the latest version. ### Manage release channels You can use branches or tags to manage different release channels, such as `stable`, `preview`, or `dev`. We recommend using your default branch as the stable release channel. This ensures that the default installation command always provides the most reliable version of your extension. You can then use a `dev` branch for active development and merge it into the default branch when you are ready for a release. ## Release through GitHub Releases Distributing extensions through [GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) provides a faster installation experience by avoiding a repository clone. Gemini CLI checks for updates by looking for the **Latest** release on GitHub. Users can also install specific versions using the `--ref` argument with a release tag. Use the `--pre-release` flag to install the latest version even if it isn't marked as **Latest**. ### Custom pre-built archives You can attach custom archives directly to your GitHub Release as assets. This is useful if your extension requires a build step or includes platform-specific binaries. Custom archives must be fully self-contained and follow the required [archive structure](#archive-structure). If your extension is platform-independent, provide a single generic asset. #### Platform-specific archives To let Gemini CLI find the correct asset for a user's platform, use the following naming convention: 1. **Platform and architecture-specific:** `{platform}.{arch}.{name}.{extension}` 2. **Platform-specific:** `{platform}.{name}.{extension}` 3. **Generic:** A single asset will be used as a fallback if no specific match is found. Use these values for the placeholders: - `{name}`: Your extension name. - `{platform}`: Use `darwin` (macOS), `linux`, or `win32` (Windows). - `{arch}`: Use `x64` or `arm64`. - `{extension}`: Use `.tar.gz` or `.zip`. **Examples:** - `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs) - `darwin.my-tool.tar.gz` (fallback for all Macs, e.g. Intel) - `linux.x64.my-tool.tar.gz` - `win32.my-tool.zip` #### Archive structure Archives must be fully contained extensions. The `gemini-extension.json` file must be at the root of the archive. The rest of the layout should match a standard extension structure. #### Example GitHub Actions workflow Use this example workflow to build and release your extension for multiple platforms: ```yaml name: Release Extension on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: npm ci - name: Build extension run: npm run build - name: Create release assets run: | npm run package -- --platform=darwin --arch=arm64 npm run package -- --platform=linux --arch=x64 npm run package -- --platform=win32 --arch=x64 - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: files: | release/darwin.arm64.my-tool.tar.gz release/linux.arm64.my-tool.tar.gz release/win32.arm64.my-tool.zip ``` ## Migrating an Extension Repository If you need to move your extension to a new repository (e.g., from a personal account to an organization) or rename it, you can use the `migratedTo` property in your `gemini-extension.json` file to seamlessly transition your users. 1. **Create the new repository**: Setup your extension in its new location. 2. **Update the old repository**: In your original repository, update the `gemini-extension.json` file to include the `migratedTo` property, pointing to the new repository URL, and bump the version number. You can optionally change the `name` of your extension at this time in the new repository. ```json { "name": "my-extension", "version": "1.1.0", "migratedTo": "https://github.com/new-owner/new-extension-repo" } ``` 3. **Release the update**: Publish this new version in your old repository. When users check for updates, the Gemini CLI will detect the `migratedTo` field, verify that the new repository contains a valid extension update, and automatically update their local installation to track the new source and name moving forward. All extension settings will automatically migrate to the new installation. ================================================ FILE: docs/extensions/writing-extensions.md ================================================ # Build Gemini CLI extensions Gemini CLI extensions let you expand the capabilities of Gemini CLI by adding custom tools, commands, and context. This guide walks you through creating your first extension, from setting up a template to adding custom functionality and linking it for local development. ## Prerequisites Before you start, ensure you have the Gemini CLI installed and a basic understanding of Node.js. ## Extension features Extensions offer several ways to customize Gemini CLI. Use this table to decide which features your extension needs. | Feature | What it is | When to use it | Invoked by | | :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | | **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model | | **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User | | **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model | | **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model | | **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI | | **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) | ## Step 1: Create a new extension The easiest way to start is by using a built-in template. We'll use the `mcp-server` example as our foundation. Run the following command to create a new directory called `my-first-extension` with the template files: ```bash gemini extensions new my-first-extension mcp-server ``` This creates a directory with the following structure: ``` my-first-extension/ ├── example.js ├── gemini-extension.json └── package.json ``` ## Step 2: Understand the extension files Your new extension contains several key files that define its behavior. ### `gemini-extension.json` The manifest file tells Gemini CLI how to load and use your extension. ```json { "name": "mcp-server-example", "version": "1.0.0", "mcpServers": { "nodeServer": { "command": "node", "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } } ``` - `name`: The unique name for your extension. - `version`: The version of your extension. - `mcpServers`: Defines Model Context Protocol (MCP) servers to add new tools. - `command`, `args`, `cwd`: Specify how to start your server. The `${extensionPath}` variable is replaced with the absolute path to your extension's directory. ### `example.js` This file contains the source code for your MCP server. It uses the `@modelcontextprotocol/sdk` to define tools. ```javascript /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; const server = new McpServer({ name: 'prompt-server', version: '1.0.0', }); // Registers a new tool named 'fetch_posts' server.registerTool( 'fetch_posts', { description: 'Fetches a list of posts from a public API.', inputSchema: z.object({}).shape, }, async () => { const apiResponse = await fetch( 'https://jsonplaceholder.typicode.com/posts', ); const posts = await apiResponse.json(); const response = { posts: posts.slice(0, 5) }; return { content: [ { type: 'text', text: JSON.stringify(response), }, ], }; }, ); const transport = new StdioServerTransport(); await server.connect(transport); ``` ### `package.json` The standard configuration file for a Node.js project. It defines dependencies and scripts for your extension. ## Step 3: Add extension settings Some extensions need configuration, such as API keys or user preferences. Let's add a setting for an API key. 1. Open `gemini-extension.json`. 2. Add a `settings` array to the configuration: ```json { "name": "mcp-server-example", "version": "1.0.0", "settings": [ { "name": "API Key", "description": "The API key for the service.", "envVar": "MY_SERVICE_API_KEY", "sensitive": true } ], "mcpServers": { // ... } } ``` When a user installs this extension, Gemini CLI will prompt them to enter the "API Key". The value will be stored securely in the system keychain (because `sensitive` is true) and injected into the MCP server's process as the `MY_SERVICE_API_KEY` environment variable. ## Step 4: Link your extension Link your extension to your Gemini CLI installation for local development. 1. **Install dependencies:** ```bash cd my-first-extension npm install ``` 2. **Link the extension:** The `link` command creates a symbolic link from the Gemini CLI extensions directory to your development directory. Changes you make are reflected immediately. ```bash gemini extensions link . ``` Restart your Gemini CLI session to use the new `fetch_posts` tool. Test it by asking: "fetch posts". ## Step 5: Add a custom command Custom commands create shortcuts for complex prompts. 1. Create a `commands` directory and a subdirectory for your command group: **macOS/Linux** ```bash mkdir -p commands/fs ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path "commands\fs" ``` 2. Create a file named `commands/fs/grep-code.toml`: ```toml prompt = """ Please summarize the findings for the pattern `{{args}}`. Search Results: !{grep -r {{args}} .} """ ``` This command, `/fs:grep-code`, takes an argument, runs the `grep` shell command, and pipes the results into a prompt for summarization. After saving the file, restart Gemini CLI. Run `/fs:grep-code "some pattern"` to use your new command. ## Step 6: Add a custom `GEMINI.md` Provide persistent context to the model by adding a `GEMINI.md` file to your extension. This is useful for setting behavior or providing essential tool information. 1. Create a file named `GEMINI.md` in the root of your extension directory: ```markdown # My First Extension Instructions You are an expert developer assistant. When the user asks you to fetch posts, use the `fetch_posts` tool. Be concise in your responses. ``` 2. Update your `gemini-extension.json` to load this file: ```json { "name": "my-first-extension", "version": "1.0.0", "contextFileName": "GEMINI.md", "mcpServers": { "nodeServer": { "command": "node", "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } } ``` Restart Gemini CLI. The model now has the context from your `GEMINI.md` file in every session where the extension is active. ## (Optional) Step 7: Add an Agent Skill [Agent Skills](../cli/skills.md) bundle specialized expertise and workflows. Skills are activated only when needed, which saves context tokens. 1. Create a `skills` directory and a subdirectory for your skill: **macOS/Linux** ```bash mkdir -p skills/security-audit ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path "skills\security-audit" ``` 2. Create a `skills/security-audit/SKILL.md` file: ```markdown --- name: security-audit description: Expertise in auditing code for security vulnerabilities. Use when the user asks to "check for security issues" or "audit" their changes. --- # Security Auditor You are an expert security researcher. When auditing code: 1. Look for common vulnerabilities (OWASP Top 10). 2. Check for hardcoded secrets or API keys. 3. Suggest remediation steps for any findings. ``` Gemini CLI automatically discovers skills bundled with your extension. The model activates them when it identifies a relevant task. ## Step 8: Release your extension When your extension is ready, share it with others via a Git repository or GitHub Releases. Refer to the [Extension Releasing Guide](./releasing.md) for detailed instructions and learn how to list your extension in the gallery. ## Next steps - [Extension reference](reference.md): Deeply understand the extension format, commands, and configuration. - [Best practices](best-practices.md): Learn strategies for building great extensions. ================================================ FILE: docs/get-started/authentication.md ================================================ # Gemini CLI authentication setup To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. > [!TIP] > Looking for a high-level comparison of all available subscriptions? > To compare features and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). For most users, we recommend starting Gemini CLI and logging in with your personal Google account. ## Choose your authentication method Select the authentication method that matches your situation in the table below: | User Type / Scenario | Recommended Authentication Method | Google Cloud Project Required | | :--------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------- | | Individual Google accounts | [Sign in with Google](#login-google) | No, with exceptions | | Organization users with a company, school, or Google Workspace account | [Sign in with Google](#login-google) | [Yes](#set-gcp) | | AI Studio user with a Gemini API key | [Use Gemini API Key](#gemini-api) | No | | Google Cloud Vertex AI user | [Vertex AI](#vertex-ai) | [Yes](#set-gcp) | | [Headless mode](#headless) | [Use Gemini API Key](#gemini-api) or
[Vertex AI](#vertex-ai) | No (for Gemini API Key)
[Yes](#set-gcp) (for Vertex AI) | ### What is my Google account type? - **Individual Google accounts:** Includes all [free tier accounts](../resources/quota-and-pricing.md#free-usage) such as Gemini Code Assist for individuals, as well as paid subscriptions for [Google AI Pro and Ultra](https://gemini.google/subscriptions/). - **Organization accounts:** Accounts using paid licenses through an organization such as a company, school, or [Google Workspace](https://workspace.google.com/). Includes [Google AI Ultra for Business](https://support.google.com/a/answer/16345165) subscriptions. ## (Recommended) Sign in with Google If you run Gemini CLI on your local machine, the simplest authentication method is logging in with your Google account. This method requires a web browser on a machine that can communicate with the terminal running Gemini CLI (for example, your local machine). If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google account associated with your subscription. To authenticate and use Gemini CLI: 1. Start the CLI: ```bash gemini ``` 2. Select **Sign in with Google**. Gemini CLI opens a sign in prompt using your web browser. Follow the on-screen instructions. Your credentials will be cached locally for future sessions. ### Do I need to set my Google Cloud project? Most individual Google accounts (free and paid) don't require a Google Cloud project for authentication. However, you'll need to set a Google Cloud project when you meet at least one of the following conditions: - You are using a company, school, or Google Workspace account. - You are using a Gemini Code Assist license from the Google Developer Program. - You are using a license from a Gemini Code Assist subscription. For instructions, see [Set your Google Cloud Project](#set-gcp). ## Use Gemini API key If you don't want to authenticate using your Google account, you can use an API key from Google AI Studio. To authenticate and use Gemini CLI with a Gemini API key: 1. Obtain your API key from [Google AI Studio](https://aistudio.google.com/app/apikey). 2. Set the `GEMINI_API_KEY` environment variable to your key. For example: **macOS/Linux** ```bash # Replace YOUR_GEMINI_API_KEY with the key from AI Studio export GEMINI_API_KEY="YOUR_GEMINI_API_KEY" ``` **Windows (PowerShell)** ```powershell # Replace YOUR_GEMINI_API_KEY with the key from AI Studio $env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY" ``` To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). 3. Start the CLI: ```bash gemini ``` 4. Select **Use Gemini API key**. > [!WARNING] > Treat API keys, especially for services like Gemini, as sensitive > credentials. Protect them to prevent unauthorized access and potential misuse > of the service under your account. ## Use Vertex AI To use Gemini CLI with Google Cloud's Vertex AI platform, choose from the following authentication options: - A. Application Default Credentials (ADC) using `gcloud`. - B. Service account JSON key. - C. Google Cloud API key. Regardless of your authentication method for Vertex AI, you'll need to set `GOOGLE_CLOUD_PROJECT` to your Google Cloud project ID with the Vertex AI API enabled, and `GOOGLE_CLOUD_LOCATION` to the location of your Vertex AI resources or the location where you want to run your jobs. For example: **macOS/Linux** ```bash # Replace with your project ID and desired location (for example, us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` **Windows (PowerShell)** ```powershell # Replace with your project ID and desired location (for example, us-central1) $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" $env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` To make any Vertex AI environment variable settings persistent, see [Persisting Environment Variables](#persisting-vars). #### A. Vertex AI - application default credentials (ADC) using `gcloud` Consider this authentication method if you have Google Cloud CLI installed. If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset them to use ADC. **macOS/Linux** ```bash unset GOOGLE_API_KEY GEMINI_API_KEY ``` **Windows (PowerShell)** ```powershell Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore ``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. 2. Log in to Google Cloud: ```bash gcloud auth application-default login ``` 3. [Configure your Google Cloud Project](#set-gcp). 4. Start the CLI: ```bash gemini ``` 5. Select **Vertex AI**. #### B. Vertex AI - service account JSON key Consider this method of authentication in non-interactive environments, CI/CD pipelines, or if your organization restricts user-based ADC or API key creation. If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset them: **macOS/Linux** ```bash unset GOOGLE_API_KEY GEMINI_API_KEY ``` **Windows (PowerShell)** ```powershell Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore ``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the service account. 2. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the JSON file's absolute path. For example: **macOS/Linux** ```bash # Replace /path/to/your/keyfile.json with the actual path export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json" ``` **Windows (PowerShell)** ```powershell # Replace C:\path\to\your\keyfile.json with the actual path $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json" ``` 3. [Configure your Google Cloud Project](#set-gcp). 4. Start the CLI: ```bash gemini ``` 5. Select **Vertex AI**. > [!WARNING] > Protect your service account key file as it gives access to > your resources. #### C. Vertex AI - Google Cloud API key 1. Obtain a Google Cloud API key: [Get an API Key](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys?usertype=newuser). 2. Set the `GOOGLE_API_KEY` environment variable: **macOS/Linux** ```bash # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` **Windows (PowerShell)** ```powershell # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` If you see errors like `"API keys are not supported by this API..."`, your organization might restrict API key usage for this service. Try the other Vertex AI authentication methods instead. 3. [Configure your Google Cloud Project](#set-gcp). 4. Start the CLI: ```bash gemini ``` 5. Select **Vertex AI**. ## Set your Google Cloud project > [!IMPORTANT] > Most individual Google accounts (free and paid) don't require a > Google Cloud project for authentication. When you sign in using your Google account, you may need to configure a Google Cloud project for Gemini CLI to use. This applies when you meet at least one of the following conditions: - You are using a Company, School, or Google Workspace account. - You are using a Gemini Code Assist license from the Google Developer Program. - You are using a license from a Gemini Code Assist subscription. To configure Gemini CLI to use a Google Cloud project, do the following: 1. [Find your Google Cloud Project ID](https://support.google.com/googleapi/answer/7014113). 2. [Enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api). 3. [Configure necessary IAM access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam). 4. Configure your environment variables. Set either the `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` variable to the project ID to use with Gemini CLI. Gemini CLI checks for `GOOGLE_CLOUD_PROJECT` first, then falls back to `GOOGLE_CLOUD_PROJECT_ID`. For example, to set the `GOOGLE_CLOUD_PROJECT_ID` variable: **macOS/Linux** ```bash # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" ``` **Windows (PowerShell)** ```powershell # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" ``` To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). ## Persisting environment variables To avoid setting environment variables for every terminal session, you can persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append the environment variable commands to your shell's startup file. **macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` **Windows (PowerShell)** (for example, `$PROFILE`): ```powershell Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' . $PROFILE ``` > [!WARNING] > Be aware that when you export API keys or service account > paths in your shell configuration file, any process launched from that > shell can read them. 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` or `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: **macOS/Linux** ```bash mkdir -p ~/.gemini cat >> ~/.gemini/.env <<'EOF' GOOGLE_CLOUD_PROJECT="your-project-id" # Add other variables like GEMINI_API_KEY as needed EOF ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini" @" GOOGLE_CLOUD_PROJECT="your-project-id" # Add other variables like GEMINI_API_KEY as needed "@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append ``` Variables are loaded from the first file found, not merged. ## Running in Google Cloud environments When running Gemini CLI within certain Google Cloud environments, authentication is automatic. In a Google Cloud Shell environment, Gemini CLI typically authenticates automatically using your Cloud Shell credentials. In Compute Engine environments, Gemini CLI automatically uses Application Default Credentials (ADC) from the environment's metadata server. If automatic authentication fails, use one of the interactive methods described on this page. ## Running in headless mode [Headless mode](../cli/headless) will use your existing authentication method, if an existing authentication credential is cached. If you have not already signed in with an authentication credential, you must configure authentication using environment variables: - [Use Gemini API Key](#gemini-api) - [Vertex AI](#vertex-ai) ## What's next? Your authentication method affects your quotas, pricing, Terms of Service, and privacy notices. Review the following pages to learn more: - [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md). - [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md). ================================================ FILE: docs/get-started/examples.md ================================================ # Gemini CLI examples Gemini CLI helps you automate common engineering tasks by combining AI reasoning with local system tools. This document provides examples of how to use the CLI for file management, code analysis, and data transformation. > [!NOTE] > These examples demonstrate potential capabilities. Your actual > results can vary based on the model used and your project environment. ## Rename your photographs based on content You can use Gemini CLI to automate file management tasks that require visual analysis. In this example, Gemini CLI renames images based on their actual subject matter. Scenario: You have a folder containing the following files: ```bash photos/photo1.png photos/photo2.png photos/photo3.png ``` Give Gemini the following prompt: ```cli Rename the photos in my "photos" directory based on their contents. ``` Result: Gemini asks for permission to rename your files. Select **Allow once** and your files are renamed: ```bash photos/yellow_flowers.png photos/antique_dresser.png photos/green_android_robot.png ``` ## Explain a repository by reading its code Gemini CLI is effective for rapid codebase exploration. The following example shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project. Scenario: You want to understand how a popular open-source utility works by inspecting its code, not just its README. Give Gemini CLI the following prompt: ```cli Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works. ``` Result: Gemini performs a sequence of actions to answer your request. 1. First, it asks for permission to run `git clone` to download the repository. 2. Next, it finds the important source files and asks for permission to read them. 3. Finally, after analyzing the code, it provides a summary. Gemini CLI returns an explanation based on the actual source code: ```markdown The `chalk` library is a popular npm package for styling terminal output with colors. After analyzing the source code, here's how it works: - **Core functionality:** The main file sets up a chainable API. Each color or modifier (like `bold` or `italic`) is a getter that appends the corresponding ANSI escape code to an internal stack. - **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing getters. The `red` getter adds the red color code, and the `bold` getter adds the bold code. - **Output generation:** When the chain is treated as a string (e.g., in `console.log`), a final `toString()` method is called. This method joins all the stored ANSI codes, wraps them around the input string ('Hello'), and adds a reset code at the end. This produces the final, styled string that the terminal can render. ``` ## Combine two spreadsheets into one spreadsheet Gemini CLI can process and transform data across multiple files. Use this capability to merge reports or reformat data sets without manual copying. Scenario: You have two .csv files: `Revenue - 2023.csv` and `Revenue - 2024.csv`. Each file contains monthly revenue figures. Give Gemini CLI the following prompt: ```cli Combine the two .csv files into a single .csv file, with each year a different column. ``` Result: Gemini CLI reads each file and then asks for permission to write a new file. Provide your permission and Gemini CLI provides the combined data: ```csv Month,2023,2024 January,0,1000 February,0,1200 March,0,2400 April,900,500 May,1000,800 June,1000,900 July,1200,1000 August,1800,400 September,2000,2000 October,2400,3400 November,3400,1800 December,2100,9000 ``` ## Run unit tests Gemini CLI can generate boilerplate code and tests based on your existing implementation. This example demonstrates how to request code coverage for a JavaScript component. Scenario: You've written a simple login page. You wish to write unit tests to ensure that your login page has code coverage. Give Gemini CLI the following prompt: ```cli Write unit tests for Login.js. ``` Result: Gemini CLI asks for permission to write a new file and creates a test for your login page. ## Next steps - Follow the [File management](../cli/tutorials/file-management.md) guide to start working with your codebase. - Follow the [Quickstart](./index.md) to start your first session. - See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of available commands. ================================================ FILE: docs/get-started/gemini-3.md ================================================ # Gemini 3 Pro and Gemini 3 Flash on Gemini CLI Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! > [!NOTE] > Gemini 3.1 Pro Preview is rolling out. To determine whether you have > access to Gemini 3.1, use the `/model` command and select **Manual**. If you > have access, you will see `gemini-3.1-pro-preview`. > > If you have access to Gemini 3.1, it will be included in model routing when > you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model > directly using the `-m` flag: > > ``` > gemini -m gemini-3.1-pro-preview > ``` > > Learn more about [models](../cli/model.md) and > [model routing](../cli/model-routing.md). ## How to get started with Gemini 3 on Gemini CLI Get started by upgrading Gemini CLI to the latest version: ```bash npm install -g @google/gemini-cli@latest ``` If your version is 0.21.1 or later: 1. Run `/model`. 2. Select **Auto (Gemini 3)**. For more information, see [Gemini CLI model selection](../cli/model.md). ### Usage limits and fallback Gemini CLI will tell you when you reach your Gemini 3 Pro daily usage limit. When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. > [!TIP] > Looking to upgrade for higher limits? To compare subscription > options and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see a message prompting fallback to Gemini 2.5 Flash. ### Capacity errors There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. > [!NOTE] > The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry > doesn't happen immediately, please wait a few minutes for the request to > process. ### Model selection and routing types When using Gemini CLI, you may want to control how your requests are routed between models. By default, Gemini CLI uses **Auto** routing. When using Gemini 3 Pro, you may want to use Auto routing or Pro routing to manage your usage limits: - **Auto routing:** Auto routing first determines whether a prompt involves a complex or simple operation. For simple prompts, it will automatically use Gemini 2.5 Flash. For complex prompts, if Gemini 3 Pro is enabled, it will use Gemini 3 Pro; otherwise, it will use Gemini 2.5 Pro. - **Pro routing:** If you want to ensure your task is processed by the most capable model, use `/model` and select **Pro**. Gemini CLI will prioritize the most capable model available, including Gemini 3 Pro if it has been enabled. To learn more about selecting a model and routing, refer to [Gemini CLI Model Selection](../cli/model.md). ## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise, enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels. Using Gemini 3 Pro will require two steps: administrative enablement and user enablement. To learn more about these settings, refer to [Configure Gemini Code Assist release channels](https://developers.google.com/gemini-code-assist/docs/configure-release-channels). ### Administrator instructions An administrator with **Google Cloud Settings Admin** permissions must follow these directions: - Navigate to the Google Cloud Project you're using with Gemini CLI for Code Assist. - Go to **Admin for Gemini** > **Settings**. - Under **Release channels for Gemini Code Assist in local IDEs** select **Preview**. - Click **Save changes**. ### User instructions Wait for two to three minutes after your administrator has enabled **Preview**, then: - Open Gemini CLI. - Use the `/settings` command. - Set **Preview Features** to `true`. Restart Gemini CLI and you should have access to Gemini 3. ## Next steps If you need help, we recommend searching for an existing [GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you cannot find a GitHub issue that matches your concern, you can [create a new issue](https://github.com/google-gemini/gemini-cli/issues/new/choose). For comments and feedback, consider opening a [GitHub discussion](https://github.com/google-gemini/gemini-cli/discussions). ================================================ FILE: docs/get-started/index.md ================================================ # Get started with Gemini CLI Welcome to Gemini CLI! This guide will help you install, configure, and start using the Gemini CLI to enhance your workflow right from your terminal. ## Quickstart: Install, authenticate, configure, and use Gemini CLI Gemini CLI brings the power of advanced language models directly to your command line interface. As an AI-powered assistant, Gemini CLI can help you with a variety of tasks, from understanding and generating code to reviewing and editing documents. ## Install The standard method to install and run Gemini CLI uses `npm`: ```bash npm install -g @google/gemini-cli ``` Once Gemini CLI is installed, run Gemini CLI from your command line: ```bash gemini ``` For more installation options, see [Gemini CLI Installation](./installation.md). ## Authenticate To begin using Gemini CLI, you must authenticate with a Google service. In most cases, you can log in with your existing Google account: 1. Run Gemini CLI after installation: ```bash gemini ``` 2. When asked "How would you like to authenticate for this project?" select **1. Sign in with Google**. 3. Select your Google account. 4. Click on **Sign in**. Certain account types may require you to configure a Google Cloud project. For more information, including other authentication methods, see [Gemini CLI Authentication Setup](./authentication.md). ## Configure Gemini CLI offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. To explore your configuration options, see [Gemini CLI Configuration](../reference/configuration.md). ## Use Once installed and authenticated, you can start using Gemini CLI by issuing commands and prompts in your terminal. Ask it to generate code, explain files, and more. To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md). ## Check usage and quota You can check your current token usage and quota information using the `/stats model` command. This command provides a snapshot of your current session's token usage, as well as your overall quota and usage for the supported models. For more information on the `/stats` command and its subcommands, see the [Command Reference](../reference/commands.md#stats). ## Next steps - Follow the [File management](../cli/tutorials/file-management.md) guide to start working with your codebase. - See [Shell commands](../cli/tutorials/shell-commands.md) to learn about terminal integration. ================================================ FILE: docs/get-started/installation.md ================================================ # Gemini CLI installation, execution, and releases This document provides an overview of Gemini CLI's system requirements, installation methods, and release types. ## Recommended system specifications - **Operating System:** - macOS 15+ - Windows 11 24H2+ - Ubuntu 20.04+ - **Hardware:** - "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits) - "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context) - **Runtime:** Node.js 20.0.0+ - **Shell:** Bash, Zsh, or PowerShell - **Location:** [Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) - **Internet connection required** ## Install Gemini CLI We recommend most users install Gemini CLI using one of the following installation methods: - npm - Homebrew - MacPorts - Anaconda Note that Gemini CLI comes pre-installed on [**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and [**Cloud Workstations**](https://cloud.google.com/workstations). ### Install globally with npm ```bash npm install -g @google/gemini-cli ``` ### Install globally with Homebrew (macOS/Linux) ```bash brew install gemini-cli ``` ### Install globally with MacPorts (macOS) ```bash sudo port install gemini-cli ``` ### Install with Anaconda (for restricted environments) ```bash # Create and activate a new environment conda create -y -n gemini_env -c conda-forge nodejs conda activate gemini_env # Install Gemini CLI globally via npm (inside the environment) npm install -g @google/gemini-cli ``` ## Run Gemini CLI For most users, we recommend running Gemini CLI with the `gemini` command: ```bash gemini ``` For a list of options and additional commands, see the [CLI cheatsheet](../cli/cli-reference.md). You can also run Gemini CLI using one of the following advanced methods: - Run instantly with npx. You can run Gemini CLI without permanent installation. - In a sandbox. This method offers increased security and isolation. - From the source. This is recommended for contributors to the project. ### Run instantly with npx ```bash # Using npx (no installation required) npx @google/gemini-cli ``` You can also execute the CLI directly from the main branch on GitHub, which is helpful for testing features still in development: ```bash npx https://github.com/google-gemini/gemini-cli ``` ### Run in a sandbox (Docker/Podman) For security and isolation, Gemini CLI can be run inside a container. This is the default way that the CLI executes tools that might have side effects. - **Directly from the registry:** You can run the published sandbox image directly. This is useful for environments where you only have Docker and want to run the CLI. ```bash # Run the published sandbox image docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1 ``` - **Using the `--sandbox` flag:** If you have Gemini CLI installed locally (using the standard installation described above), you can instruct it to run inside the sandbox container. ```bash gemini --sandbox -y -p "your prompt here" ``` ### Run from source (recommended for Gemini CLI contributors) Contributors to the project will want to run the CLI directly from the source code. - **Development mode:** This method provides hot-reloading and is useful for active development. ```bash # From the root of the repository npm run start ``` - **Production-like mode (linked package):** This method simulates a global installation by linking your local package. It's useful for testing a local build in a production workflow. ```bash # Link the local cli package to your global node_modules npm link packages/cli # Now you can run your local version using the `gemini` command gemini ``` ## Releases Gemini CLI has three release channels: nightly, preview, and stable. For most users, we recommend the stable release, which is the default installation. ### Stable New stable releases are published each week. The stable release is the promotion of last week's `preview` release along with any bug fixes. The stable release uses `latest` tag, but omitting the tag also installs the latest stable release by default: ```bash # Both commands install the latest stable release. npm install -g @google/gemini-cli npm install -g @google/gemini-cli@latest ``` ### Preview New preview releases will be published each week. These releases are not fully vetted and may contain regressions or other outstanding issues. Try out the preview release by using the `preview` tag: ```bash npm install -g @google/gemini-cli@preview ``` ### Nightly Nightly releases are published every day. The nightly release includes all changes from the main branch at time of release. It should be assumed there are pending validations and issues. You can help test the latest changes by installing with the `nightly` tag: ```bash npm install -g @google/gemini-cli@nightly ``` ================================================ FILE: docs/hooks/best-practices.md ================================================ # Hooks Best Practices This guide covers security considerations, performance optimization, debugging techniques, and privacy considerations for developing and deploying hooks in Gemini CLI. ## Performance ### Keep hooks fast Hooks run synchronously—slow hooks delay the agent loop. Optimize for speed by using parallel operations: ```javascript // Sequential operations are slower const data1 = await fetch(url1).then((r) => r.json()); const data2 = await fetch(url2).then((r) => r.json()); // Prefer parallel operations for better performance // Start requests concurrently const p1 = fetch(url1).then((r) => r.json()); const p2 = fetch(url2).then((r) => r.json()); // Wait for all results const [data1, data2] = await Promise.all([p1, p2]); ``` ### Cache expensive operations Store results between invocations to avoid repeated computation, especially for hooks that run frequently (like `BeforeTool` or `AfterModel`). ```javascript const fs = require('fs'); const path = require('path'); const CACHE_FILE = '.gemini/hook-cache.json'; function readCache() { try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; } } function writeCache(data) { fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2)); } async function main() { const cache = readCache(); const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache if (cache[cacheKey]) { // Write JSON to stdout console.log(JSON.stringify(cache[cacheKey])); return; } // Expensive operation const result = await computeExpensiveResult(); cache[cacheKey] = result; writeCache(cache); console.log(JSON.stringify(result)); } ``` ### Use appropriate events Choose hook events that match your use case to avoid unnecessary execution. - **`AfterAgent`**: Fires **once** per turn after the model finishes its final response. Use this for quality validation (Retries) or final logging. - **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for real-time redaction, PII filtering, or monitoring output as it streams. If you only need to check the final completion, use `AfterAgent` to save performance. ### Filter with matchers Use specific matchers to avoid unnecessary hook execution. Instead of matching all tools with `*`, specify only the tools you need. This saves the overhead of spawning a process for irrelevant events. ```json { "matcher": "write_file|replace", "hooks": [ { "name": "validate-writes", "type": "command", "command": "./validate.sh" } ] } ``` ### Optimize JSON parsing For large inputs (like `AfterModel` receiving a large context), standard JSON parsing can be slow. If you only need one field, consider streaming parsers or lightweight extraction logic, though for most shell scripts `jq` is sufficient. ## Debugging ### The "Strict JSON" rule The most common cause of hook failure is "polluting" the standard output. - **stdout** is for **JSON only**. - **stderr** is for **logs and text**. **Good:** ```bash #!/bin/bash echo "Starting check..." >&2 # <--- Redirect to stderr echo '{"decision": "allow"}' ``` ### Log to files Since hooks run in the background, writing to a dedicated log file is often the easiest way to debug complex logic. ```bash #!/usr/bin/env bash LOG_FILE=".gemini/hooks/debug.log" # Log with timestamp log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" } input=$(cat) log "Received input: ${input:0:100}..." # Hook logic here log "Hook completed successfully" # Always output valid JSON to stdout at the end, even if just empty echo "{}" ``` ### Use stderr for errors Error messages on stderr are surfaced appropriately based on exit codes: ```javascript try { const result = dangerousOperation(); console.log(JSON.stringify({ result })); } catch (error) { // Write the error description to stderr so the user/agent sees it console.error(`Hook error: ${error.message}`); process.exit(2); // Blocking error } ``` ### Test hooks independently Run hook scripts manually with sample JSON input to verify they behave as expected before hooking them up to the CLI. **macOS/Linux** ```bash # Create test input cat > test-input.json << 'EOF' { "session_id": "test-123", "cwd": "/tmp/test", "hook_event_name": "BeforeTool", "tool_name": "write_file", "tool_input": { "file_path": "test.txt", "content": "Test content" } } EOF # Test the hook cat test-input.json | .gemini/hooks/my-hook.sh # Check exit code echo "Exit code: $?" ``` **Windows (PowerShell)** ```powershell # Create test input @" { "session_id": "test-123", "cwd": "C:\\temp\\test", "hook_event_name": "BeforeTool", "tool_name": "write_file", "tool_input": { "file_path": "test.txt", "content": "Test content" } } "@ | Out-File -FilePath test-input.json -Encoding utf8 # Test the hook Get-Content test-input.json | .\.gemini\hooks\my-hook.ps1 # Check exit code Write-Host "Exit code: $LASTEXITCODE" ``` ### Check exit codes Gemini CLI uses exit codes for high-level flow control: - **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for JSON decisions. - **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the reason. - For **Agent/Model** events, this aborts the turn. - For **Tool** events, this blocks the tool but allows the agent to continue. - For **AfterAgent**, this triggers an automatic retry turn. > **TIP** > > **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a > **specific action**. Use `{"continue": false}` in your JSON output to **kill > the entire agent loop** immediately. ```bash #!/usr/bin/env bash set -e # Hook logic if process_input; then echo '{"decision": "allow"}' exit 0 else echo "Critical validation failure" >&2 exit 2 fi ``` ### Enable telemetry Hook execution is logged when `telemetry.logPrompts` is enabled. You can view these logs to debug execution flow. ```json { "telemetry": { "logPrompts": true } } ``` ### Use hook panel The `/hooks panel` command inside the CLI shows execution status and recent output: ```bash /hooks panel ``` Check for: - Hook execution counts - Recent successes/failures - Error messages - Execution timing ## Development ### Start simple Begin with basic logging hooks before implementing complex logic: ```bash #!/usr/bin/env bash # Simple logging hook to understand input structure input=$(cat) echo "$input" >> .gemini/hook-inputs.log # Always return valid JSON echo "{}" ``` ### Documenting your hooks Maintainability is critical for complex hook systems. Use descriptions and comments to help yourself and others understand why a hook exists. **Use the `description` field**: This text is displayed in the `/hooks panel` UI and helps diagnose issues. ```json { "hooks": { "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "secret-scanner", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", "description": "Scans code changes for API keys and secrets before writing" } ] } ] } } ``` **Add comments in hook scripts**: Explain performance expectations and dependencies. ```javascript #!/usr/bin/env node /** * RAG Tool Filter Hook * * Reduces the tool space by extracting keywords from the user's request. * * Performance: ~500ms average * Dependencies: @google/generative-ai */ ``` ### Use JSON libraries Parse JSON with proper libraries instead of text processing. **Bad:** ```bash # Fragile text parsing tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') ``` **Good:** ```bash # Robust JSON parsing tool_name=$(echo "$input" | jq -r '.tool_name') ``` ### Make scripts executable Always make hook scripts executable on macOS/Linux: ```bash chmod +x .gemini/hooks/*.sh chmod +x .gemini/hooks/*.js ``` **Windows Note**: On Windows, PowerShell scripts (`.ps1`) don't use `chmod`, but you may need to ensure your execution policy allows them to run (e.g., `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`). ### Version control Commit hooks to share with your team: ```bash git add .gemini/hooks/ git add .gemini/settings.json ``` **`.gitignore` considerations:** ```gitignore # Ignore hook cache and logs .gemini/hook-cache.json .gemini/hook-debug.log .gemini/memory/session-*.jsonl # Keep hook scripts !.gemini/hooks/*.sh !.gemini/hooks/*.js ``` ## Hook security ### Threat Model Understanding where hooks come from and what they can do is critical for secure usage. | Hook Source | Description | | :---------------------------- | :------------------------------------------------------------------------------------------------------------------------- | | **System** | Configured by system administrators (e.g., `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. | | **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. | | **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). | | **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. | #### Project Hook Security When you open a project with hooks defined in `.gemini/settings.json`: 1. **Detection**: Gemini CLI detects the hooks. 2. **Identification**: A unique identity is generated for each hook based on its `name` and `command`. 3. **Warning**: If this specific hook identity has not been seen before, a **warning** is displayed. 4. **Execution**: The hook is executed (unless specific security settings block it). 5. **Trust**: The hook is marked as "trusted" for this project. > **Modification detection**: If the `command` string of a project hook is > changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat > it as a **new, untrusted hook** and warn you again. This prevents malicious > actors from silently swapping a verified command for a malicious one. ### Risks | Risk | Description | | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | **Arbitrary Code Execution** | Hooks run as your user. They can do anything you can do (delete files, install software). | | **Data Exfiltration** | A hook could read your input (prompts), output (code), or environment variables (`GEMINI_API_KEY`) and send them to a remote server. | | **Prompt Injection** | Malicious content in a file or web page could trick an LLM into running a tool that triggers a hook in an unexpected way. | ### Mitigation Strategies #### Verify the source **Verify the source** of any project hooks or extensions before enabling them. - For open-source projects, a quick review of the hook scripts is recommended. - For extensions, ensure you trust the author or publisher (e.g., verified publishers, well-known community members). - Be cautious with obfuscated scripts or compiled binaries from unknown sources. #### Sanitize environment Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a [redaction system](../reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). > **Disabled by Default**: Environment redaction is currently **OFF by > default**. We strongly recommend enabling it if you are running third-party > hooks or working in sensitive environments. **Impact on hooks:** - **Security**: Prevents your hook scripts from accidentally leaking secrets. - **Troubleshooting**: If your hook depends on a specific environment variable that is being blocked, you must explicitly allow it in `settings.json`. ```json { "security": { "environmentVariableRedaction": { "enabled": true, "allowed": ["MY_REQUIRED_TOOL_KEY"] } } } ``` **System administrators:** You can enforce redaction for all users in the system configuration. ## Troubleshooting ### Hook not executing **Check hook name in `/hooks panel`:** Verify the hook appears in the list and is enabled. **Verify matcher pattern:** ```bash # Test regex pattern echo "write_file|replace" | grep -E "write_.*|replace" ``` **Check disabled list:** Verify the hook is not listed in your `settings.json`: ```json { "hooks": { "disabled": ["my-hook-name"] } } ``` **Ensure script is executable**: For macOS and Linux users, verify the script has execution permissions: ```bash ls -la .gemini/hooks/my-hook.sh chmod +x .gemini/hooks/my-hook.sh ``` **Windows Note**: On Windows, ensure your execution policy allows running scripts (e.g., `Get-ExecutionPolicy`). **Verify script path:** Ensure the path in `settings.json` resolves correctly. ```bash # Check path expansion echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" # Verify file exists test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" ``` ### Hook timing out **Check configured timeout:** The default is 60000ms (1 minute). You can increase this in `settings.json`: ```json { "name": "slow-hook", "timeout": 120000 } ``` **Optimize slow operations:** Move heavy processing to background tasks or use caching. ### Invalid JSON output **Validate JSON before outputting:** ```bash #!/usr/bin/env bash output='{"decision": "allow"}' # Validate JSON if echo "$output" | jq empty 2>/dev/null; then echo "$output" else echo "Invalid JSON generated" >&2 exit 1 fi ``` ### Environment variables not available **Check if variable is set:** ```bash #!/usr/bin/env bash if [ -z "$GEMINI_PROJECT_DIR" ]; then echo "GEMINI_PROJECT_DIR not set" >&2 exit 1 fi ``` **Debug available variables:** ```bash env > .gemini/hook-env.log ``` ## Authoring secure hooks When writing your own hooks, follow these practices to ensure they are robust and secure. ### Validate all inputs Never trust data from hooks without validation. Hook inputs often come from the LLM or user prompts, which can be manipulated. ```bash #!/usr/bin/env bash input=$(cat) # Validate JSON structure if ! echo "$input" | jq empty 2>/dev/null; then echo "Invalid JSON input" >&2 exit 1 fi # Validate tool_name explicitly tool_name=$(echo "$input" | jq -r '.tool_name // empty') if [[ "$tool_name" != "write_file" && "$tool_name" != "read_file" ]]; then echo "Unexpected tool: $tool_name" >&2 exit 1 fi ``` ### Use timeouts Prevent denial-of-service (hanging agents) by enforcing timeouts. Gemini CLI defaults to 60 seconds, but you should set stricter limits for fast hooks. ```json { "hooks": { "BeforeTool": [ { "matcher": "*", "hooks": [ { "name": "fast-validator", "type": "command", "command": "./hooks/validate.sh", "timeout": 5000 // 5 seconds } ] } ] } } ``` ### Limit permissions Run hooks with minimal required permissions: ```bash #!/usr/bin/env bash # Don't run as root if [ "$EUID" -eq 0 ]; then echo "Hook should not run as root" >&2 exit 1 fi # Check file permissions before writing if [ -w "$file_path" ]; then # Safe to write else echo "Insufficient permissions" >&2 exit 1 fi ``` ### Example: Secret Scanner Use `BeforeTool` hooks to prevent committing sensitive data. This is a powerful pattern for enhancing security in your workflow. ```javascript const SECRET_PATTERNS = [ /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, /AKIA[0-9A-Z]{16}/, // AWS access key /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token /sk-[a-zA-Z0-9]{48}/, // OpenAI API key ]; function containsSecret(content) { return SECRET_PATTERNS.some((pattern) => pattern.test(content)); } ``` ## Privacy considerations Hook inputs and outputs may contain sensitive information. ### What data is collected Hook telemetry may include inputs (prompts, code) and outputs (decisions, reasons) unless disabled. ### Privacy settings **Disable PII logging:** If you are working with sensitive data, disable prompt logging in your settings: ```json { "telemetry": { "logPrompts": false } } ``` **Suppress Output:** Individual hooks can request their metadata be hidden from logs and telemetry by returning `"suppressOutput": true` in their JSON response. > **Note** > `suppressOutput` only affects background logging. Any `systemMessage` or > `reason` included in the JSON will still be displayed to the user in the > terminal. ### Sensitive data in hooks If your hooks process sensitive data: 1. **Minimize logging:** Don't write sensitive data to log files. 2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing to stderr. ================================================ FILE: docs/hooks/index.md ================================================ # Gemini CLI hooks Hooks are scripts or programs that Gemini CLI executes at specific points in the agentic loop, allowing you to intercept and customize behavior without modifying the CLI's source code. ## What are hooks? Hooks run synchronously as part of the agent loop—when a hook event fires, Gemini CLI waits for all matching hooks to complete before continuing. With hooks, you can: - **Add context:** Inject relevant information (like git history) before the model processes a request. - **Validate actions:** Review tool arguments and block potentially dangerous operations. - **Enforce policies:** Implement security scanners and compliance checks. - **Log interactions:** Track tool usage and model responses for auditing. - **Optimize behavior:** Dynamically filter available tools or adjust model parameters. ### Getting started - **[Writing hooks guide](../hooks/writing-hooks)**: A tutorial on creating your first hook with comprehensive examples. - **[Best practices](../hooks/best-practices)**: Guidelines on security, performance, and debugging. - **[Hooks reference](../hooks/reference)**: The definitive technical specification of I/O schemas and exit codes. ## Core concepts ### Hook events Hooks are triggered by specific events in Gemini CLI's lifecycle. | Event | When It Fires | Impact | Common Use Cases | | --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- | | `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context | | `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state | | `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns | | `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution | | `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses | | `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions | | `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection | | `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops | | `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results | | `PreCompress` | Before context compression | Advisory | Save state, notify user | | `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging | ### Global mechanics Understanding these core principles is essential for building robust hooks. #### Strict JSON requirements (The "Golden Rule") Hooks communicate via `stdin` (Input) and `stdout` (Output). 1. **Silence is Mandatory**: Your script **must not** print any plain text to `stdout` other than the final JSON object. **Even a single `echo` or `print` call before the JSON will break parsing.** 2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will fail. The CLI will default to "Allow" and treat the entire output as a `systemMessage`. 3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g., `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse it as JSON. #### Exit codes Gemini CLI uses exit codes to determine the high-level outcome of a hook execution: | Exit Code | Label | Behavioral Impact | | --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). | | **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. | | **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. | #### Matchers You can filter which specific tools or triggers fire your hook using the `matcher` field. - **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular Expressions**. (e.g., `"write_.*"`). - **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`). - **Wildcards**: `"*"` or `""` (empty string) matches all occurrences. ## Configuration Hooks are configured in `settings.json`. Gemini CLI merges configurations from multiple layers in the following order of precedence (highest to lowest): 1. **Project settings**: `.gemini/settings.json` in the current directory. 2. **User settings**: `~/.gemini/settings.json`. 3. **System settings**: `/etc/gemini-cli/settings.json`. 4. **Extensions**: Hooks defined by installed extensions. ### Configuration schema ```json { "hooks": { "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "security-check", "type": "command", "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh", "timeout": 5000 } ] } ] } } ``` #### Hook configuration fields | Field | Type | Required | Description | | :------------ | :----- | :-------- | :------------------------------------------------------------------- | | `type` | string | **Yes** | The execution engine. Currently only `"command"` is supported. | | `command` | string | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | | `name` | string | No | A friendly name for identifying the hook in logs and CLI commands. | | `timeout` | number | No | Execution timeout in milliseconds (default: 60000). | | `description` | string | No | A brief explanation of the hook's purpose. | --- ### Environment variables Hooks are executed with a sanitized environment. - `GEMINI_PROJECT_DIR`: The absolute path to the project root. - `GEMINI_SESSION_ID`: The unique ID for the current session. - `GEMINI_CWD`: The current working directory. - `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. ## Security and risks > [!WARNING] > Hooks execute arbitrary code with your user privileges. By > configuring hooks, you are allowing scripts to run shell commands on your > machine. **Project-level hooks** are particularly risky when opening untrusted projects. Gemini CLI **fingerprints** project hooks. If a hook's name or command changes (e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will be warned before it executes. See [Security Considerations](../hooks/best-practices#using-hooks-securely) for a detailed threat model. ## Managing hooks Use the CLI commands to manage hooks without editing JSON manually: - **View hooks:** `/hooks panel` - **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all` - **Toggle individual:** `/hooks enable ` or `/hooks disable ` ================================================ FILE: docs/hooks/reference.md ================================================ # Hooks reference This document provides the technical specification for Gemini CLI hooks, including JSON schemas and API details. ## Global hook mechanics - **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and `stderr` for logs and feedback. - **Exit codes**: - `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.** - `2`: System Block. The action is blocked; `stderr` is used as the rejection reason. - `Other`: Warning. A non-fatal failure occurred; the CLI continues with a warning. - **Silence is Mandatory**: Your script **must not** print any plain text to `stdout` other than the final JSON. --- ## Configuration schema Hooks are defined in `settings.json` within the `hooks` object. Each event (e.g., `BeforeTool`) contains an array of **hook definitions**. ### Hook definition | Field | Type | Required | Description | | :----------- | :-------- | :------- | :-------------------------------------------------------------------------------------- | | `matcher` | `string` | No | A regex (for tools) or exact string (for lifecycle) to filter when the hook runs. | | `sequential` | `boolean` | No | If `true`, hooks in this group run one after another. If `false`, they run in parallel. | | `hooks` | `array` | **Yes** | An array of **hook configurations**. | ### Hook configuration | Field | Type | Required | Description | | :------------ | :------- | :-------- | :------------------------------------------------------------------- | | `type` | `string` | **Yes** | The execution engine. Currently only `"command"` is supported. | | `command` | `string` | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | | `name` | `string` | No | A friendly name for identifying the hook in logs and CLI commands. | | `timeout` | `number` | No | Execution timeout in milliseconds (default: 60000). | | `description` | `string` | No | A brief explanation of the hook's purpose. | --- ## Base input schema All hooks receive these common fields via `stdin`: ```typescript { "session_id": string, // Unique ID for the current session "transcript_path": string, // Absolute path to session transcript JSON "cwd": string, // Current working directory "hook_event_name": string, // The firing event (e.g. "BeforeTool") "timestamp": string // ISO 8601 execution time } ``` --- ## Common output fields Most hooks support these fields in their `stdout` JSON: | Field | Type | Description | | :--------------- | :-------- | :----------------------------------------------------------------------------- | | `systemMessage` | `string` | Displayed immediately to the user in the terminal. | | `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. | | `continue` | `boolean` | If `false`, stops the entire agent loop immediately. | | `stopReason` | `string` | Displayed to the user when `continue` is `false`. | | `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. | | `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. | --- ## Tool hooks ### Matchers and tool names For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is compared against the name of the tool being executed. - **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, `run_shell_command`). See the [Tools Reference](../reference/tools) for a full list of available tool names. - **MCP Tools**: Tools from MCP servers follow the naming pattern `mcp__`. - **Regex Support**: Matchers support regular expressions (e.g., `matcher: "read_.*"` matches all file reading tools). ### `BeforeTool` Fires before a tool is invoked. Used for argument validation, security checks, and parameter rewriting. - **Input Fields**: - `tool_name`: (`string`) The name of the tool being called. - `tool_input`: (`object`) The raw arguments generated by the model. - `mcp_context`: (`object`) Optional metadata for MCP-based tools. - `original_request_name`: (`string`) The original name of the tool being called, if this is a tail tool call. - **Relevant Output Fields**: - `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from executing. - `reason`: Required if denied. This text is sent **to the agent** as a tool error, allowing it to respond or retry. - `hookSpecificOutput.tool_input`: An object that **merges with and overrides** the model's arguments before execution. - `continue`: Set to `false` to **kill the entire agent loop** immediately. - **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the `reason` sent to the agent. **The turn continues.** ### `AfterTool` Fires after a tool executes. Used for result auditing, context injection, or hiding sensitive output from the agent. - **Input Fields**: - `tool_name`: (`string`) - `tool_input`: (`object`) The original arguments. - `tool_response`: (`object`) The result containing `llmContent`, `returnDisplay`, and optional `error`. - `mcp_context`: (`object`) - `original_request_name`: (`string`) The original name of the tool being called, if this is a tail tool call. - **Relevant Output Fields**: - `decision`: Set to `"deny"` to hide the real tool output from the agent. - `reason`: Required if denied. This text **replaces** the tool result sent back to the model. - `hookSpecificOutput.additionalContext`: Text that is **appended** to the tool result for the agent. - `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`) A request to execute another tool immediately after this one. The result of this "tail call" will replace the original tool's response. Ideal for programmatic tool routing. - `continue`: Set to `false` to **kill the entire agent loop** immediately. - **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the replacement content sent to the agent. **The turn continues.** --- ## Agent hooks ### `BeforeAgent` Fires after a user submits a prompt, but before the agent begins planning. Used for prompt validation or injecting dynamic context. - **Input Fields**: - `prompt`: (`string`) The original text submitted by the user. - **Relevant Output Fields**: - `hookSpecificOutput.additionalContext`: Text that is **appended** to the prompt for this turn only. - `decision`: Set to `"deny"` to block the turn and **discard the user's message** (it will not appear in history). - `continue`: Set to `false` to block the turn but **save the message to history**. - `reason`: Required if denied or stopped. - **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from context. Same as `decision: "deny"`. ### `AfterAgent` Fires once per turn after the model generates its final response. Primary use case is response validation and automatic retries. - **Input Fields**: - `prompt`: (`string`) The user's original request. - `prompt_response`: (`string`) The final text generated by the agent. - `stop_hook_active`: (`boolean`) Indicates if this hook is already running as part of a retry sequence. - **Relevant Output Fields**: - `decision`: Set to `"deny"` to **reject the response** and force a retry. - `reason`: Required if denied. This text is sent **to the agent as a new prompt** to request a correction. - `continue`: Set to `false` to **stop the session** without retrying. - `hookSpecificOutput.clearContext`: If `true`, clears conversation history (LLM memory) while preserving UI display. - **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry turn using `stderr` as the feedback prompt. --- ## Model hooks ### `BeforeModel` Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic request format. - **Input Fields**: - `llm_request`: (`object`) Contains `model`, `messages`, and `config` (generation params). - **Relevant Output Fields**: - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the outgoing request (e.g., changing models or temperature). - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If provided, the CLI skips the LLM call entirely and uses this as the response. - `decision`: Set to `"deny"` to block the request and abort the turn. - **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses `stderr` as the error message. ### `BeforeToolSelection` Fires before the LLM decides which tools to call. Used to filter the available toolset or force specific tool modes. - **Input Fields**: - `llm_request`: (`object`) Same format as `BeforeModel`. - **Relevant Output Fields**: - `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`) - `"NONE"`: Disables all tools (Wins over other hooks). - `"ANY"`: Forces at least one tool call. - `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist of tool names. - **Union Strategy**: Multiple hooks' whitelists are **combined**. - **Limitations**: Does **not** support `decision`, `continue`, or `systemMessage`. ### `AfterModel` Fires immediately after an LLM response chunk is received. Used for real-time redaction or PII filtering. - **Input Fields**: - `llm_request`: (`object`) The original request. - `llm_response`: (`object`) The model's response (or a single chunk during streaming). - **Relevant Output Fields**: - `hookSpecificOutput.llm_response`: An object that **replaces** the model's response chunk. - `decision`: Set to `"deny"` to discard the response chunk and block the turn. - `continue`: Set to `false` to **kill the entire agent loop** immediately. - **Note on Streaming**: Fired for **every chunk** generated by the model. Modifying the response only affects the current chunk. - **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's output. Uses `stderr` as the error message. --- ## Lifecycle & system hooks ### `SessionStart` Fires on application startup, resuming a session, or after a `/clear` command. Used for loading initial context. - **Input fields**: - `source`: (`"startup" | "resume" | "clear"`) - **Relevant output fields**: - `hookSpecificOutput.additionalContext`: (`string`) - **Interactive**: Injected as the first turn in history. - **Non-interactive**: Prepended to the user's prompt. - `systemMessage`: Shown at the start of the session. - **Advisory only**: `continue` and `decision` fields are **ignored**. Startup is never blocked. ### `SessionEnd` Fires when the CLI exits or a session is cleared. Used for cleanup or final telemetry. - **Input Fields**: - `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`) - **Relevant Output Fields**: - `systemMessage`: Displayed to the user during shutdown. - **Best Effort**: The CLI **will not wait** for this hook to complete and ignores all flow-control fields (`continue`, `decision`). ### `Notification` Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for external logging or cross-platform alerts. - **Input Fields**: - `notification_type`: (`"ToolPermission"`) - `message`: Summary of the alert. - `details`: JSON object with alert-specific metadata (e.g., tool name, file path). - **Relevant Output Fields**: - `systemMessage`: Displayed alongside the system alert. - **Observability Only**: This hook **cannot** block alerts or grant permissions automatically. Flow-control fields are ignored. ### `PreCompress` Fires before the CLI summarizes history to save tokens. Used for logging or state saving. - **Input Fields**: - `trigger`: (`"auto" | "manual"`) - **Relevant Output Fields**: - `systemMessage`: Displayed to the user before compression. - **Advisory Only**: Fired asynchronously. It **cannot** block or modify the compression process. Flow-control fields are ignored. --- ## Stable Model API Gemini CLI uses these structures to ensure hooks don't break across SDK updates. **LLMRequest**: ```typescript { "model": string, "messages": Array<{ "role": "user" | "model" | "system", "content": string // Non-text parts are filtered out for hooks }>, "config": { "temperature": number, ... }, "toolConfig": { "mode": string, "allowedFunctionNames": string[] } } ``` **LLMResponse**: ```typescript { "candidates": Array<{ "content": { "role": "model", "parts": string[] }, "finishReason": string }>, "usageMetadata": { "totalTokenCount": number } } ``` ================================================ FILE: docs/hooks/writing-hooks.md ================================================ # Writing hooks for Gemini CLI This guide will walk you through creating hooks for Gemini CLI, from a simple logging hook to a comprehensive workflow assistant. ## Prerequisites Before you start, make sure you have: - Gemini CLI installed and configured - Basic understanding of shell scripting or JavaScript/Node.js - Familiarity with JSON for hook input/output ## Quick start Let's create a simple hook that logs all tool executions to understand the basics. **Crucial Rule:** Always write logs to `stderr`. Write only the final JSON to `stdout`. ### Step 1: Create your hook script Create a directory for hooks and a simple logging script. > **Note**: > > This example uses `jq` to parse JSON. If you don't have it installed, you can > perform similar logic using Node.js or Python. **macOS/Linux** ```bash mkdir -p .gemini/hooks cat > .gemini/hooks/log-tools.sh << 'EOF' #!/usr/bin/env bash # Read hook input from stdin input=$(cat) # Extract tool name (requires jq) tool_name=$(echo "$input" | jq -r '.tool_name') # Log to stderr (visible in terminal if hook fails, or captured in logs) echo "Logging tool: $tool_name" >&2 # Log to file echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt # Return success (exit 0) with empty JSON echo "{}" exit 0 EOF chmod +x .gemini/hooks/log-tools.sh ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path ".gemini\hooks" @" # Read hook input from stdin `$inputJson = `$input | Out-String | ConvertFrom-Json # Extract tool name `$toolName = `$inputJson.tool_name # Log to stderr (visible in terminal if hook fails, or captured in logs) [Console]::Error.WriteLine("Logging tool: `$toolName") # Log to file "[`$(Get-Date -Format 'o')] Tool executed: `$toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8 # Return success with empty JSON "{}" "@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8 ``` ## Exit Code Strategies There are two ways to control or block an action in Gemini CLI: | Strategy | Exit Code | Implementation | Best For | | :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- | | **Structured (Idiomatic)** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}`. | Production hooks, custom user feedback, and complex logic. | | **Emergency Brake** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. | ## Practical examples ### Security: Block secrets in commits Prevent committing files containing API keys or passwords. Note that we use **Exit Code 0** to provide a structured denial message to the agent. **`.gemini/hooks/block-secrets.sh`:** ```bash #!/usr/bin/env bash input=$(cat) # Extract content being written content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""') # Check for secrets if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then # Log to stderr echo "Blocked potential secret" >&2 # Return structured denial to stdout cat </dev/null || echo "No git history") # Return as JSON cat < m.role === 'user'); if (!lastUserMessage) { console.log(JSON.stringify({})); // Do nothing return; } const text = lastUserMessage.content; const allowed = ['write_todos']; // Always allow memory // Simple keyword matching if (text.includes('read') || text.includes('check')) { allowed.push('read_file', 'list_directory'); } if (text.includes('test')) { allowed.push('run_shell_command'); } // If we found specific intent, filter tools. Otherwise allow all. if (allowed.length > 1) { console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeToolSelection', toolConfig: { mode: 'ANY', // Force usage of one of these tools (or AUTO) allowedFunctionNames: allowed, }, }, }), ); } else { console.log(JSON.stringify({})); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` **`.gemini/settings.json`:** ```json { "hooks": { "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { "name": "intent-filter", "type": "command", "command": "node .gemini/hooks/filter-tools.js" } ] } ] } } ``` > **TIP** > > **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it > combines the results of all matching hooks. If you have multiple filtering > hooks, the agent will receive the **union** of all whitelisted tools. Only > using `mode: "NONE"` will override other hooks to disable all tools. ## Complete example: Smart Development Workflow Assistant This comprehensive example demonstrates all hook events working together. We will build a system that maintains memory, filters tools, and checks for security. ### Architecture 1. **SessionStart**: Load project memories. 2. **BeforeAgent**: Inject memories into context. 3. **BeforeToolSelection**: Filter tools based on intent. 4. **BeforeTool**: Scan for secrets. 5. **AfterModel**: Record interactions. 6. **AfterAgent**: Validate final response quality (Retry). 7. **SessionEnd**: Consolidate memories. ### Configuration (`.gemini/settings.json`) ```json { "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [ { "name": "init", "type": "command", "command": "node .gemini/hooks/init.js" } ] } ], "BeforeAgent": [ { "matcher": "*", "hooks": [ { "name": "memory", "type": "command", "command": "node .gemini/hooks/inject-memories.js" } ] } ], "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { "name": "filter", "type": "command", "command": "node .gemini/hooks/rag-filter.js" } ] } ], "BeforeTool": [ { "matcher": "write_file", "hooks": [ { "name": "security", "type": "command", "command": "node .gemini/hooks/security.js" } ] } ], "AfterModel": [ { "matcher": "*", "hooks": [ { "name": "record", "type": "command", "command": "node .gemini/hooks/record.js" } ] } ], "AfterAgent": [ { "matcher": "*", "hooks": [ { "name": "validate", "type": "command", "command": "node .gemini/hooks/validate.js" } ] } ], "SessionEnd": [ { "matcher": "exit", "hooks": [ { "name": "save", "type": "command", "command": "node .gemini/hooks/consolidate.js" } ] } ] } } ``` ### Hook Scripts > **Note**: For brevity, these scripts use `console.error` for logging and > standard `console.log` for JSON output. #### 1. Initialize (`init.js`) ```javascript #!/usr/bin/env node // Initialize DB or resources console.error('Initializing assistant...'); // Output to user console.log( JSON.stringify({ systemMessage: '🧠 Smart Assistant Loaded', }), ); ``` #### 2. Inject Memories (`inject-memories.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); async function main() { const input = JSON.parse(fs.readFileSync(0, 'utf-8')); // Assume we fetch memories from a DB here const memories = '- [Memory] Always use TypeScript for this project.'; console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeAgent', additionalContext: `\n## Relevant Memories\n${memories}`, }, }), ); } main(); ``` #### 3. Security Check (`security.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0)); const content = input.tool_input.content || ''; if (content.includes('SECRET_KEY')) { console.log( JSON.stringify({ decision: 'deny', reason: 'Found SECRET_KEY in content', systemMessage: '🚨 Blocked sensitive commit', }), ); process.exit(0); } console.log(JSON.stringify({ decision: 'allow' })); ``` #### 4. Record Interaction (`record.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); const input = JSON.parse(fs.readFileSync(0)); const { llm_request, llm_response } = input; const logFile = path.join( process.env.GEMINI_PROJECT_DIR, '.gemini/memory/session.jsonl', ); fs.appendFileSync( logFile, JSON.stringify({ request: llm_request, response: llm_response, timestamp: new Date().toISOString(), }) + '\n', ); console.log(JSON.stringify({})); ``` #### 5. Validate Response (`validate.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0)); const response = input.prompt_response; // Example: Check if the agent forgot to include a summary if (!response.includes('Summary:')) { console.log( JSON.stringify({ decision: 'block', // Triggers an automatic retry turn reason: 'Your response is missing a Summary section. Please add one.', systemMessage: '🔄 Requesting missing summary...', }), ); process.exit(0); } console.log(JSON.stringify({ decision: 'allow' })); ``` #### 6. Consolidate Memories (`consolidate.js`) ```javascript #!/usr/bin/env node // Logic to save final session state console.error('Consolidating memories for session end...'); ``` ## Packaging as an extension While project-level hooks are great for specific repositories, you can share your hooks across multiple projects by packaging them as a [Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md). This provides version control, easy distribution, and centralized management. ================================================ FILE: docs/ide-integration/ide-companion-spec.md ================================================ # Gemini CLI companion plugin: Interface specification > Last Updated: September 15, 2025 This document defines the contract for building a companion plugin to enable Gemini CLI's IDE mode. For VS Code, these features (native diffing, context awareness) are provided by the official extension ([marketplace](https://marketplace.visualstudio.com/items?itemName=Google.gemini-cli-vscode-ide-companion)). This specification is for contributors who wish to bring similar functionality to other editors like JetBrains IDEs, Sublime Text, etc. ## I. The communication interface Gemini CLI and the IDE plugin communicate through a local communication channel. ### 1. Transport layer: MCP over HTTP The plugin **MUST** run a local HTTP server that implements the **Model Context Protocol (MCP)**. - **Protocol:** The server must be a valid MCP server. We recommend using an existing MCP SDK for your language of choice if available. - **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication. - **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`). ### 2. Discovery mechanism: The port file For Gemini CLI to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file." - **How the CLI finds the file:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name. - **File location:** The file must be created in a specific directory: `os.tmpdir()/gemini/ide/`. Your plugin must create this directory if it doesn't exist. - **File naming convention:** The filename is critical and **MUST** follow the pattern: `gemini-ide-server-${PID}-${PORT}.json` - `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename. - `${PORT}`: The port your MCP server is listening on. - **File content and workspace validation:** The file **MUST** contain a JSON object with the following structure: ```json { "port": 12345, "workspacePath": "/path/to/project1:/path/to/project2", "authToken": "a-very-secret-token", "ideInfo": { "name": "vscode", "displayName": "VS Code" } } ``` - `port` (number, required): The port of the MCP server. - `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer ` header on all requests. - `ideInfo` (object, required): Information about the IDE. - `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). - `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). - **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. - **Tie-breaking with environment variables (recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `GEMINI_CLI_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. ## II. The context interface To enable context awareness, the plugin **MAY** provide the CLI with real-time information about the user's activity in the IDE. ### `ide/contextUpdate` notification The plugin **MAY** send an `ide/contextUpdate` [notification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications) to the CLI whenever the user's context changes. - **Triggering events:** This notification should be sent (with a recommended debounce of 50ms) when: - A file is opened, closed, or focused. - The user's cursor position or text selection changes in the active file. - **Payload (`IdeContext`):** The notification parameters **MUST** be an `IdeContext` object: ```typescript interface IdeContext { workspaceState?: { openFiles?: File[]; isTrusted?: boolean; }; } interface File { // Absolute path to the file path: string; // Last focused Unix timestamp (for ordering) timestamp: number; // True if this is the currently focused file isActive?: boolean; cursor?: { // 1-based line number line: number; // 1-based character number character: number; }; // The text currently selected by the user selectedText?: string; } ``` > [!NOTE] > The `openFiles` list should only include files that exist on disk. > Virtual files (e.g., unsaved files without a path, editor settings pages) > **MUST** be excluded. ### How the CLI uses this context After receiving the `IdeContext` object, the CLI performs several normalization and truncation steps before sending the information to the model. - **File ordering:** The CLI uses the `timestamp` field to determine the most recently used files. It sorts the `openFiles` list based on this value. Therefore, your plugin **MUST** provide an accurate Unix timestamp for when a file was last focused. - **Active file:** The CLI considers only the most recent file (after sorting) to be the "active" file. It will ignore the `isActive` flag on all other files and clear their `cursor` and `selectedText` fields. Your plugin should focus on setting `isActive: true` and providing cursor/selection details only for the currently focused file. - **Truncation:** To manage token limits, the CLI truncates both the file list (to 10 files) and the `selectedText` (to 16KB). While the CLI handles the final truncation, it is highly recommended that your plugin also limits the amount of context it sends. ## III. The diffing interface To enable interactive code modifications, the plugin **MAY** expose a diffing interface. This allows the CLI to request that the IDE open a diff view, showing proposed changes to a file. The user can then review, edit, and ultimately accept or reject these changes directly within the IDE. ### `openDiff` tool The plugin **MUST** register an `openDiff` tool on its MCP server. - **Description:** This tool instructs the IDE to open a modifiable diff view for a specific file. - **Request (`OpenDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `OpenDiffRequest` object. ```typescript interface OpenDiffRequest { // The absolute path to the file to be diffed. filePath: string; // The proposed new content for the file. newContent: string; } ``` - **Response (`CallToolResult`):** The tool **MUST** immediately return a `CallToolResult` to acknowledge the request and report whether the diff view was successfully opened. - On Success: If the diff view was opened successfully, the response **MUST** contain empty content (i.e., `content: []`). - On Failure: If an error prevented the diff view from opening, the response **MUST** have `isError: true` and include a `TextContent` block in the `content` array describing the error. The actual outcome of the diff (acceptance or rejection) is communicated asynchronously via notifications. ### `closeDiff` tool The plugin **MUST** register a `closeDiff` tool on its MCP server. - **Description:** This tool instructs the IDE to close an open diff view for a specific file. - **Request (`CloseDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `CloseDiffRequest` object. ```typescript interface CloseDiffRequest { // The absolute path to the file whose diff view should be closed. filePath: string; } ``` - **Response (`CallToolResult`):** The tool **MUST** return a `CallToolResult`. - On Success: If the diff view was closed successfully, the response **MUST** include a single **TextContent** block in the content array containing the file's final content before closing. - On Failure: If an error prevented the diff view from closing, the response **MUST** have `isError: true` and include a `TextContent` block in the `content` array describing the error. ### `ide/diffAccepted` notification When the user accepts the changes in a diff view (e.g., by clicking an "Apply" or "Save" button), the plugin **MUST** send an `ide/diffAccepted` notification to the CLI. - **Payload:** The notification parameters **MUST** include the file path and the final content of the file. The content may differ from the original `newContent` if the user made manual edits in the diff view. ```typescript { // The absolute path to the file that was diffed. filePath: string; // The full content of the file after acceptance. content: string; } ``` ### `ide/diffRejected` notification When the user rejects the changes (e.g., by closing the diff view without accepting), the plugin **MUST** send an `ide/diffRejected` notification to the CLI. - **Payload:** The notification parameters **MUST** include the file path of the rejected diff. ```typescript { // The absolute path to the file that was diffed. filePath: string; } ``` ## IV. The lifecycle interface The plugin **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle. - **On activation (IDE startup/plugin enabled):** 1. Start the MCP server. 2. Create the discovery file. - **On deactivation (IDE shutdown/plugin disabled):** 1. Stop the MCP server. 2. Delete the discovery file. ================================================ FILE: docs/ide-integration/index.md ================================================ # IDE integration Gemini CLI can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing. Currently, the supported IDEs are [Antigravity](https://antigravity.google), [Visual Studio Code](https://code.visualstudio.com/), and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](./ide-companion-spec.md). ## Features - **Workspace context:** The CLI automatically gains awareness of your workspace to provide more relevant and accurate responses. This context includes: - The **10 most recently accessed files** in your workspace. - Your active cursor position. - Any text you have selected (up to a 16KB limit; longer selections will be truncated). - **Native diffing:** When Gemini suggests code modifications, you can view the changes directly within your IDE's native diff viewer. This allows you to review, edit, and accept or reject the suggested changes seamlessly. - **VS Code commands:** You can access Gemini CLI features directly from the VS Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`): - `Gemini CLI: Run`: Starts a new Gemini CLI session in the integrated terminal. - `Gemini CLI: Accept Diff`: Accepts the changes in the active diff editor. - `Gemini CLI: Close Diff Editor`: Rejects the changes and closes the active diff editor. - `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for the extension. ## Installation and setup There are three ways to set up the IDE integration: ### 1. Automatic nudge (recommended) When you run Gemini CLI inside a supported editor, it will automatically detect your environment and prompt you to connect. Answering "Yes" will automatically run the necessary setup, which includes installing the companion extension and enabling the connection. ### 2. Manual installation from CLI If you previously dismissed the prompt or want to install the extension manually, you can run the following command inside Gemini CLI: ``` /ide install ``` This will find the correct extension for your IDE and install it. ### 3. Manual installation from a marketplace You can also install the extension directly from a marketplace. - **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion). - **For VS Code forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry. > [!NOTE] > The "Gemini CLI Companion" extension may appear towards the bottom of > search results. If you don't see it immediately, try scrolling down or > sorting by "Newly Published". > > After manually installing the extension, you must run `/ide enable` in the CLI > to activate the integration. ## Usage ### Enabling and disabling You can control the IDE integration from within the CLI: - To enable the connection to the IDE, run: ``` /ide enable ``` - To disable the connection, run: ``` /ide disable ``` When enabled, Gemini CLI will automatically attempt to connect to the IDE companion extension. ### Checking the status To check the connection status and see the context the CLI has received from the IDE, run: ``` /ide status ``` If connected, this command will show the IDE it's connected to and a list of recently opened files it is aware of. > [!NOTE] > The file list is limited to 10 recently accessed files within your > workspace and only includes local files on disk.) ### Working with diffs When you ask Gemini to modify a file, it can open a diff view directly in your editor. **To accept a diff**, you can perform any of the following actions: - Click the **checkmark icon** in the diff editor's title bar. - Save the file (e.g., with `Cmd+S` or `Ctrl+S`). - Open the Command Palette and run **Gemini CLI: Accept Diff**. - Respond with `yes` in the CLI when prompted. **To reject a diff**, you can: - Click the **'x' icon** in the diff editor's title bar. - Close the diff editor tab. - Open the Command Palette and run **Gemini CLI: Close Diff Editor**. - Respond with `no` in the CLI when prompted. You can also **modify the suggested changes** directly in the diff view before accepting them. If you select ‘Allow for this session’ in the CLI, changes will no longer show up in the IDE as they will be auto-accepted. ## Using with sandboxing If you are using Gemini CLI within a sandbox, please be aware of the following: - **On macOS:** The IDE integration requires network access to communicate with the IDE companion extension. You must use a Seatbelt profile that allows network access. - **In a Docker container:** If you run Gemini CLI inside a Docker (or Podman) container, the IDE integration can still connect to the VS Code extension running on your host machine. The CLI is configured to automatically find the IDE server on `host.docker.internal`. No special configuration is usually required, but you may need to ensure your Docker networking setup allows connections from the container to the host. ## Troubleshooting If you encounter issues with IDE integration, here are some common error messages and how to resolve them. ### Connection errors - **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.` - **Cause:** Gemini CLI could not find the necessary environment variables (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly. - **Solution:** 1. Make sure you have installed the **Gemini CLI Companion** extension in your IDE and that it is enabled. 2. Open a new terminal window in your IDE to ensure it picks up the correct environment. - **Message:** `🔴 Disconnected: IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable` - **Cause:** The connection to the IDE companion was lost. - **Solution:** Run `/ide enable` to try and reconnect. If the issue continues, open a new terminal window or restart your IDE. ### Manual PID override If automatic IDE detection fails, or if you are running Gemini CLI in a standalone terminal and want to manually associate it with a specific IDE instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the process ID (PID) of your IDE. **macOS/Linux** ```bash export GEMINI_CLI_IDE_PID=12345 ``` **Windows (PowerShell)** ```powershell $env:GEMINI_CLI_IDE_PID=12345 ``` When this variable is set, Gemini CLI will skip automatic detection and attempt to connect using the provided PID. ### Configuration errors - **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]` - **Cause:** The CLI's current working directory is outside the workspace you have open in your IDE. - **Solution:** `cd` into the same directory that is open in your IDE and restart the CLI. - **Message:** `🔴 Disconnected: To use this feature, please open a workspace folder in [IDE Name] and try again.` - **Cause:** You have no workspace open in your IDE. - **Solution:** Open a workspace in your IDE and restart the CLI. ### General errors - **Message:** `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]` - **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE. - **Solution:** Run Gemini CLI from the integrated terminal of a supported IDE, like Antigravity or VS Code. - **Message:** `No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.` - **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE. - **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and [install it manually](#3-manual-installation-from-a-marketplace). ================================================ FILE: docs/index.md ================================================ # Gemini CLI documentation Gemini CLI brings the power of Gemini models directly into your terminal. Use it to understand code, automate tasks, and build workflows with your local project context. ## Install ```bash npm install -g @google/gemini-cli ``` ## Get started Jump in to Gemini CLI. - **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI. - **[Installation](./get-started/installation.md):** How to install Gemini CLI on your system. - **[Authentication](./get-started/authentication.md):** Setup instructions for personal and enterprise accounts. - **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in action. - **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common commands and options. - **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3 support in Gemini CLI. ## Use Gemini CLI User-focused guides and tutorials for daily development workflows. - **[File management](./cli/tutorials/file-management.md):** How to work with local files and directories. - **[Get started with Agent skills](./cli/tutorials/skills-getting-started.md):** Getting started with specialized expertise. - **[Manage context and memory](./cli/tutorials/memory-management.md):** Managing persistent instructions and facts. - **[Execute shell commands](./cli/tutorials/shell-commands.md):** Executing system commands safely. - **[Manage sessions and history](./cli/tutorials/session-management.md):** Resuming, managing, and rewinding conversations. - **[Plan tasks with todos](./cli/tutorials/task-planning.md):** Using todos for complex workflows. - **[Web search and fetch](./cli/tutorials/web-tools.md):** Searching and fetching content from the web. - **[Set up an MCP server](./cli/tutorials/mcp-setup.md):** Set up an MCP server. - **[Automate tasks](./cli/tutorials/automation.md):** Automate tasks. ## Features Technical documentation for each capability of Gemini CLI. - **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and capabilities. - **[Agent Skills](./cli/skills.md):** Use specialized agents for specific tasks. - **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots. - **[Headless mode](./cli/headless.md):** Programmatic and scripting interface. - **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts. - **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with your favorite IDE. - **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents. - **[Model routing](./cli/model-routing.md):** Automatic fallback resilience. - **[Model selection](./cli/model.md):** Choose the best model for your needs. - **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for planning complex changes. - **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific tasks. - **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using remote agents. - **[Rewind](./cli/rewind.md):** Rewind and replay sessions. - **[Sandboxing](./cli/sandbox.md):** Isolate tool execution. - **[Settings](./cli/settings.md):** Full configuration reference. - **[Telemetry](./cli/telemetry.md):** Usage and performance metric details. - **[Token caching](./cli/token-caching.md):** Performance optimization. ## Configuration Settings and customization options for Gemini CLI. - **[Custom commands](./cli/custom-commands.md):** Personalized shortcuts. - **[Enterprise configuration](./cli/enterprise.md):** Professional environment controls. - **[Ignore files (.geminiignore)](./cli/gemini-ignore.md):** Exclusion pattern reference. - **[Model configuration](./cli/generation-settings.md):** Fine-tune generation parameters like temperature and thinking budget. - **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of context files. - **[System prompt override](./cli/system-prompt.md):** Instruction replacement logic. - **[Themes](./cli/themes.md):** UI personalization technical guide. - **[Trusted folders](./cli/trusted-folders.md):** Security permission logic. ## Reference Deep technical documentation and API specifications. - **[Command reference](./reference/commands.md):** Detailed slash command guide. - **[Configuration reference](./reference/configuration.md):** Settings and environment variables. - **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity tips. - **[Memory import processor](./reference/memport.md):** How Gemini CLI processes memory from various sources. - **[Policy engine](./reference/policy-engine.md):** Fine-grained execution control. - **[Tools reference](./reference/tools.md):** Information on how tools are defined, registered, and used. ## Resources Support, release history, and legal information. - **[FAQ](./resources/faq.md):** Answers to frequently asked questions. - **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing details. - **[Terms and privacy](./resources/tos-privacy.md):** Official notices and terms. - **[Troubleshooting](./resources/troubleshooting.md):** Common issues and solutions. - **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI. ## Development - **[Contribution guide](/docs/contributing):** How to contribute to Gemini CLI. - **[Integration testing](./integration-tests.md):** Running integration tests. - **[Issue and PR automation](./issue-and-pr-automation.md):** Automation for issues and pull requests. - **[Local development](./local-development.md):** Setting up a local development environment. - **[NPM package structure](./npm.md):** The structure of the NPM packages. ## Releases - **[Release notes](./changelogs/index.md):** Release notes for all versions. - **[Stable release](./changelogs/latest.md):** The latest stable release. - **[Preview release](./changelogs/preview.md):** The latest preview release. ================================================ FILE: docs/integration-tests.md ================================================ # Integration tests This document provides information about the integration testing framework used in this project. ## Overview The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They execute the built binary in a controlled environment and verify that it behaves as expected when interacting with the file system. These tests are located in the `integration-tests` directory and are run using a custom test runner. ## Building the tests Prior to running any integration tests, you need to create a release bundle that you want to actually test: ```bash npm run bundle ``` You must re-run this command after making any changes to the CLI source code, but not after making changes to tests. ## Running the tests The integration tests are not run as part of the default `npm run test` command. They must be run explicitly using the `npm run test:integration:all` script. The integration tests can also be run using the following shortcut: ```bash npm run test:e2e ``` ## Running a specific set of tests To run a subset of test files, you can use `npm run ....` where <integration test command> is either `test:e2e` or `test:integration*` and `` is any of the `.test.js` files in the `integration-tests/` directory. For example, the following command runs `list_directory.test.js` and `write_file.test.js`: ```bash npm run test:e2e list_directory write_file ``` ### Running a single test by name To run a single test by its name, use the `--test-name-pattern` flag: ```bash npm run test:e2e -- --test-name-pattern "reads a file" ``` ### Regenerating model responses Some integration tests use faked out model responses, which may need to be regenerated from time to time as the implementations change. To regenerate these golden files, set the REGENERATE_MODEL_GOLDENS environment variable to "true" when running the tests, for example: **WARNING**: If running locally you should review these updated responses for any information about yourself or your system that gemini may have included in these responses. ```bash REGENERATE_MODEL_GOLDENS="true" npm run test:e2e ``` **WARNING**: Make sure you run **await rig.cleanup()** at the end of your test, else the golden files will not be updated. ### Deflaking a test Before adding a **new** integration test, you should test it at least 5 times with the deflake script or workflow to make sure that it is not flaky. ### Deflake script ```bash npm run deflake -- --runs=5 --command="npm run test:e2e -- -- --test-name-pattern ''" ``` #### Deflake workflow ```bash gh workflow run deflake.yml --ref -f test_name_pattern="" ``` ### Running all tests To run the entire suite of integration tests, use the following command: ```bash npm run test:integration:all ``` ### Sandbox matrix The `all` command will run tests for `no sandboxing`, `docker` and `podman`. Each individual type can be run using the following commands: ```bash npm run test:integration:sandbox:none ``` ```bash npm run test:integration:sandbox:docker ``` ```bash npm run test:integration:sandbox:podman ``` ## Diagnostics The integration test runner provides several options for diagnostics to help track down test failures. ### Keeping test output You can preserve the temporary files created during a test run for inspection. This is useful for debugging issues with file system operations. To keep the test output set the `KEEP_OUTPUT` environment variable to `true`. ```bash KEEP_OUTPUT=true npm run test:integration:sandbox:none ``` When output is kept, the test runner will print the path to the unique directory for the test run. ### Verbose output For more detailed debugging, set the `VERBOSE` environment variable to `true`. ```bash VERBOSE=true npm run test:integration:sandbox:none ``` When using `VERBOSE=true` and `KEEP_OUTPUT=true` in the same command, the output is streamed to the console and also saved to a log file within the test's temporary directory. The verbose output is formatted to clearly identify the source of the logs: ``` --- TEST: : --- ... output from the gemini command ... --- END TEST: : --- ``` ## Linting and formatting To ensure code quality and consistency, the integration test files are linted as part of the main build process. You can also manually run the linter and auto-fixer. ### Running the linter To check for linting errors, run the following command: ```bash npm run lint ``` You can include the `:fix` flag in the command to automatically fix any fixable linting errors: ```bash npm run lint:fix ``` ## Directory structure The integration tests create a unique directory for each test run inside the `.integration-tests` directory. Within this directory, a subdirectory is created for each test file, and within that, a subdirectory is created for each individual test case. This structure makes it easy to locate the artifacts for a specific test run, file, or case. ``` .integration-tests/ └── / └── .test.js/ └── / ├── output.log └── ...other test artifacts... ``` ## Continuous integration To ensure the integration tests are always run, a GitHub Actions workflow is defined in `.github/workflows/chained_e2e.yml`. This workflow automatically runs the integrations tests for pull requests against the `main` branch, or when a pull request is added to a merge queue. The workflow runs the tests in different sandboxing environments to ensure Gemini CLI is tested across each: - `sandbox:none`: Runs the tests without any sandboxing. - `sandbox:docker`: Runs the tests in a Docker container. - `sandbox:podman`: Runs the tests in a Podman container. ================================================ FILE: docs/issue-and-pr-automation.md ================================================ # Automation and triage processes This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots. ## Guiding principle: Issues and pull requests First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. > [!NOTE] > Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. --- ## Detailed automation workflows Here is a breakdown of the specific automation workflows that run in our repository. ### 1. When you open an issue: `Automated Issue Triage` This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels. - **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml` - **When it runs**: Immediately after an issue is created or reopened. - **What it does**: - It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines. - **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`). - **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`). - **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact. - **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information. - **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version. - **What you should do**: - Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be. - If the `status/need-information` label is added, please provide the requested details in a comment. ### 2. When you open a pull request: `Continuous Integration (CI)` This workflow ensures that all changes meet our quality standards before they can be merged. - **Workflow File**: `.github/workflows/ci.yml` - **When it runs**: On every push to a pull request. - **What it does**: - **Lint**: Checks that your code adheres to our project's formatting and style rules. - **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process. - **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests. - **What you should do**: - Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful. - If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix. ### 3. Ongoing triage for pull requests: `PR Auditing and Label Sync` This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels. - **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml` - **When it runs**: Every 15 minutes on all open pull requests. - **What it does**: - **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`). - **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked. - **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present. - **What you should do**: - **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #` to your PR description. - This will ensure your PR is correctly categorized and moves through the review process smoothly. ### 4. Ongoing triage for issues: `Scheduled Issue Triage` This is a fallback workflow to ensure that no issue gets missed by the triage process. - **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml` - **When it runs**: Every hour on all open issues. - **What it does**: - It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label. - It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels. - **What you should do**: - You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails. ### 5. Automatic unassignment of inactive contributors: `Unassign Inactive Issue Assignees` To keep the list of open `help wanted` issues accessible to all contributors, this workflow automatically removes **external contributors** who have not opened a linked pull request within **7 days** of being assigned. Maintainers, org members, and repo collaborators with write access or above are always exempt and will never be auto-unassigned. - **Workflow File**: `.github/workflows/unassign-inactive-assignees.yml` - **When it runs**: Every day at 09:00 UTC, and can be triggered manually with an optional `dry_run` mode. - **What it does**: 1. Finds every open issue labeled `help wanted` that has at least one assignee. 2. Identifies privileged users (team members, repo collaborators with write+ access, maintainers) and skips them entirely. 3. For each remaining (external) assignee it reads the issue's timeline to determine: - The exact date they were assigned (using `assigned` timeline events). - Whether they have opened a PR that is already linked/cross-referenced to the issue. 4. Each cross-referenced PR is fetched to verify it is **ready for review**: open and non-draft, or already merged. Draft PRs do not count. 5. If an assignee has been assigned for **more than 7 days** and no qualifying PR is found, they are automatically unassigned and a comment is posted explaining the reason and how to re-claim the issue. 6. Assignees who have a non-draft, open or merged PR linked to the issue are **never** unassigned by this workflow. - **What you should do**: - **Open a real PR, not a draft**: Within 7 days of being assigned, open a PR that is ready for review and include `Fixes #` in the description. Draft PRs do not satisfy the requirement and will not prevent auto-unassignment. - **Re-assign if unassigned by mistake**: Comment `/assign` on the issue to assign yourself again. - **Unassign yourself** if you can no longer work on the issue by commenting `/unassign`, so other contributors can pick it up right away. ### 6. Release automation This workflow handles the process of packaging and publishing new versions of the Gemini CLI. - **Workflow File**: `.github/workflows/release-manual.yml` - **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases. - **What it does**: - Automatically builds the project, bumps the version numbers, and publishes the packages to npm. - Creates a corresponding release on GitHub with generated release notes. - **What you should do**: - As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release. We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask! ================================================ FILE: docs/local-development.md ================================================ # Local development guide This guide provides instructions for setting up and using local development features for Gemini CLI. ## Tracing Gemini CLI uses OpenTelemetry (OTel) to record traces that help you debug agent behavior. Traces instrument key events like model calls, tool scheduler operations, and tool calls. Traces provide deep visibility into agent behavior and help you debug complex issues. They are captured automatically when you enable telemetry. ### View traces You can view traces using Genkit Developer UI, Jaeger, or Google Cloud. #### Use Genkit Genkit provides a web-based UI for viewing traces and other telemetry data. 1. **Start the Genkit telemetry server:** Run the following command to start the Genkit server: ```bash npm run telemetry -- --target=genkit ``` The script will output the URL for the Genkit Developer UI. For example: `Genkit Developer UI: http://localhost:4000` 2. **Run Gemini CLI:** In a separate terminal, run your Gemini CLI command: ```bash gemini ``` 3. **View the traces:** Open the Genkit Developer UI URL in your browser and navigate to the **Traces** tab to view the traces. #### Use Jaeger You can view traces in the Jaeger UI for local development. 1. **Start the telemetry collector:** Run the following command in your terminal to download and start Jaeger and an OTel collector: ```bash npm run telemetry -- --target=local ``` This command configures your workspace for local telemetry and provides a link to the Jaeger UI (usually `http://localhost:16686`). - **Collector logs:** `~/.gemini/tmp//otel/collector.log` 2. **Run Gemini CLI:** In a separate terminal, run your Gemini CLI command: ```bash gemini ``` 3. **View the traces:** After running your command, open the Jaeger UI link in your browser to view the traces. #### Use Google Cloud You can use an OpenTelemetry collector to forward telemetry data to Google Cloud Trace for custom processing or routing. > [!WARNING] > Ensure you complete the > [Google Cloud telemetry prerequisites](./cli/telemetry.md#prerequisites) > (Project ID, authentication, IAM roles, and APIs) before using this method. 1. **Configure `.gemini/settings.json`:** ```json { "telemetry": { "enabled": true, "target": "gcp", "useCollector": true } } ``` 2. **Start the telemetry collector:** Run the following command to start a local OTel collector that forwards to Google Cloud: ```bash npm run telemetry -- --target=gcp ``` The script outputs links to view traces, metrics, and logs in the Google Cloud Console. - **Collector logs:** `~/.gemini/tmp//otel/collector-gcp.log` 3. **Run Gemini CLI:** In a separate terminal, run your Gemini CLI command: ```bash gemini ``` 4. **View logs, metrics, and traces:** After sending prompts, view your data in the Google Cloud Console. See the [telemetry documentation](./cli/telemetry.md#view-google-cloud-telemetry) for links to Logs, Metrics, and Trace explorers. For more detailed information on telemetry, see the [telemetry documentation](./cli/telemetry.md). ### Instrument code with traces You can add traces to your own code for more detailed instrumentation. Adding traces helps you debug and understand the flow of execution. Use the `runInDevTraceSpan` function to wrap any section of code in a trace span. Here is a basic example: ```typescript import { runInDevTraceSpan } from '@google/gemini-cli-core'; import { GeminiCliOperation } from '@google/gemini-cli-core/lib/telemetry/constants.js'; await runInDevTraceSpan( { operation: GeminiCliOperation.ToolCall, attributes: { [GEN_AI_AGENT_NAME]: 'gemini-cli', }, }, async ({ metadata }) => { // metadata allows you to record the input and output of the // operation as well as other attributes. metadata.input = { key: 'value' }; // Set custom attributes. metadata.attributes['custom.attribute'] = 'custom.value'; // Your code to be traced goes here. try { const output = await somethingRisky(); metadata.output = output; return output; } catch (e) { metadata.error = e; throw e; } }, ); ``` In this example: - `operation`: The operation type of the span, represented by the `GeminiCliOperation` enum. - `metadata.input`: (Optional) An object containing the input data for the traced operation. - `metadata.output`: (Optional) An object containing the output data from the traced operation. - `metadata.attributes`: (Optional) A record of custom attributes to add to the span. - `metadata.error`: (Optional) An error object to record if the operation fails. ================================================ FILE: docs/mermaid/context.mmd ================================================ graph LR %% --- Style Definitions --- classDef new fill:#98fb98,color:#000 classDef changed fill:#add8e6,color:#000 classDef unchanged fill:#f0f0f0,color:#000 %% --- Subgraphs --- subgraph "Context Providers" direction TB A["gemini.tsx"] B["AppContainer.tsx"] end subgraph "Contexts" direction TB CtxSession["SessionContext"] CtxVim["VimModeContext"] CtxSettings["SettingsContext"] CtxApp["AppContext"] CtxConfig["ConfigContext"] CtxUIState["UIStateContext"] CtxUIActions["UIActionsContext"] end subgraph "Component Consumers" direction TB ConsumerApp["App"] ConsumerAppContainer["AppContainer"] ConsumerAppHeader["AppHeader"] ConsumerDialogManager["DialogManager"] ConsumerHistoryItem["HistoryItemDisplay"] ConsumerComposer["Composer"] ConsumerMainContent["MainContent"] ConsumerNotifications["Notifications"] end %% --- Provider -> Context Connections --- A -.-> CtxSession A -.-> CtxVim A -.-> CtxSettings B -.-> CtxApp B -.-> CtxConfig B -.-> CtxUIState B -.-> CtxUIActions B -.-> CtxSettings %% --- Context -> Consumer Connections --- CtxSession -.-> ConsumerAppContainer CtxSession -.-> ConsumerApp CtxVim -.-> ConsumerAppContainer CtxVim -.-> ConsumerComposer CtxVim -.-> ConsumerApp CtxSettings -.-> ConsumerAppContainer CtxSettings -.-> ConsumerAppHeader CtxSettings -.-> ConsumerDialogManager CtxSettings -.-> ConsumerApp CtxApp -.-> ConsumerAppHeader CtxApp -.-> ConsumerNotifications CtxConfig -.-> ConsumerAppHeader CtxConfig -.-> ConsumerHistoryItem CtxConfig -.-> ConsumerComposer CtxConfig -.-> ConsumerDialogManager CtxUIState -.-> ConsumerApp CtxUIState -.-> ConsumerMainContent CtxUIState -.-> ConsumerComposer CtxUIState -.-> ConsumerDialogManager CtxUIActions -.-> ConsumerComposer CtxUIActions -.-> ConsumerDialogManager %% --- Apply Styles --- %% New Elements (Green) class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new %% Heavily Changed Elements (Blue) class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed %% Mostly Unchanged Elements (Gray) class CtxSession,CtxVim,CtxSettings unchanged %% --- Link Styles --- %% CtxSession (Red) linkStyle 0,8,9 stroke:#e57373,stroke-width:2px %% CtxVim (Orange) linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px %% CtxSettings (Yellow) linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px %% CtxApp (Green) linkStyle 3,17,18 stroke:#81c784,stroke-width:2px %% CtxConfig (Blue) linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px %% CtxUIState (Indigo) linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px %% CtxUIActions (Violet) linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px ================================================ FILE: docs/mermaid/render-path.mmd ================================================ graph TD %% --- Style Definitions --- classDef new fill:#98fb98,color:#000 classDef changed fill:#add8e6,color:#000 classDef unchanged fill:#f0f0f0,color:#000 classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px classDef container fill:#f5f5f5,color:#000,stroke:#ccc %% --- Component Tree --- subgraph "Entry Point" A["gemini.tsx"] end subgraph "State & Logic Wrapper" B["AppContainer.tsx"] end subgraph "Primary Layout" C["App.tsx"] end A -.-> B B -.-> C subgraph "UI Containers" direction LR C -.-> D["MainContent"] C -.-> G["Composer"] C -.-> F["DialogManager"] C -.-> E["Notifications"] end subgraph "MainContent" direction TB D -.-> H["AppHeader"] D -.-> I["HistoryItemDisplay"]:::dispatcher D -.-> L["ShowMoreLines"] end subgraph "Composer" direction TB G -.-> K_Prompt["InputPrompt"] G -.-> K_Footer["Footer"] end subgraph "DialogManager" F -.-> J["Various Dialogs
(Auth, Theme, Settings, etc.)"] end %% --- Apply Styles --- class B,D,E,F,G,H,J,K_Prompt,L new class A,C,I changed class K_Footer unchanged %% --- Link Styles --- %% MainContent Branch (Blue) linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px %% Composer Branch (Green) linkStyle 3,9,10 stroke:#81c784,stroke-width:2px %% DialogManager Branch (Orange) linkStyle 4,11 stroke:#ffb74d,stroke-width:2px %% Notifications Branch (Violet) linkStyle 5 stroke:#ba68c8,stroke-width:2px ================================================ FILE: docs/npm.md ================================================ # Package overview This monorepo contains two main packages: `@google/gemini-cli` and `@google/gemini-cli-core`. ## `@google/gemini-cli` This is the main package for the Gemini CLI. It is responsible for the user interface, command parsing, and all other user-facing functionality. When this package is published, it is bundled into a single executable file. This bundle includes all of the package's dependencies, including `@google/gemini-cli-core`. This means that whether a user installs the package with `npm install -g @google/gemini-cli` or runs it directly with `npx @google/gemini-cli`, they are using this single, self-contained executable. ## `@google/gemini-cli-core` This package contains the core logic for interacting with the Gemini API. It is responsible for making API requests, handling authentication, and managing the local cache. This package is not bundled. When it is published, it is published as a standard Node.js package with its own dependencies. This allows it to be used as a standalone package in other projects, if needed. All transpiled js code in the `dist` folder is included in the package. ## NPM workspaces This project uses [NPM Workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces) to manage the packages within this monorepo. This simplifies development by allowing us to manage dependencies and run scripts across multiple packages from the root of the project. ### How it works The root `package.json` file defines the workspaces for this project: ```json { "workspaces": ["packages/*"] } ``` This tells NPM that any folder inside the `packages` directory is a separate package that should be managed as part of the workspace. ### Benefits of workspaces - **Simplified dependency management**: Running `npm install` from the root of the project will install all dependencies for all packages in the workspace and link them together. This means you don't need to run `npm install` in each package's directory. - **Automatic linking**: Packages within the workspace can depend on each other. When you run `npm install`, NPM will automatically create symlinks between the packages. This means that when you make changes to one package, the changes are immediately available to other packages that depend on it. - **Simplified script execution**: You can run scripts in any package from the root of the project using the `--workspace` flag. For example, to run the `build` script in the `cli` package, you can run `npm run build --workspace @google/gemini-cli`. ================================================ FILE: docs/redirects.json ================================================ { "/docs/architecture": "/docs/cli/index", "/docs/cli/commands": "/docs/reference/commands", "/docs/cli": "/docs", "/docs/cli/index": "/docs", "/docs/cli/keyboard-shortcuts": "/docs/reference/keyboard-shortcuts", "/docs/cli/uninstall": "/docs/resources/uninstall", "/docs/core/concepts": "/docs", "/docs/core/memport": "/docs/reference/memport", "/docs/core/policy-engine": "/docs/reference/policy-engine", "/docs/core/tools-api": "/docs/reference/tools", "/docs/reference/tools-api": "/docs/reference/tools", "/docs/faq": "/docs/resources/faq", "/docs/get-started/configuration": "/docs/reference/configuration", "/docs/get-started/configuration-v1": "/docs/reference/configuration", "/docs/index": "/docs", "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", "/docs/tos-privacy": "/docs/resources/tos-privacy", "/docs/troubleshooting": "/docs/resources/troubleshooting" } ================================================ FILE: docs/reference/commands.md ================================================ # CLI commands Gemini CLI supports several built-in commands to help you manage your session, customize the interface, and control its behavior. These commands are prefixed with a forward slash (`/`), an at symbol (`@`), or an exclamation mark (`!`). ## Slash commands (`/`) Slash commands provide meta-level control over the CLI itself. ### Built-in Commands ### `/about` - **Description:** Show version info. Share this information when filing issues. ### `/agents` - **Description:** Manage local and remote subagents. - **Note:** This command is experimental and requires `experimental.enableAgents: true` in your `settings.json`. - **Sub-commands:** - **`list`**: - **Description:** Lists all discovered agents, including built-in, local, and remote agents. - **Usage:** `/agents list` - **`reload`** (alias: `refresh`): - **Description:** Rescans agent directories (`~/.gemini/agents` and `.gemini/agents`) and reloads the registry. - **Usage:** `/agents reload` - **`enable`**: - **Description:** Enables a specific subagent. - **Usage:** `/agents enable ` - **`disable`**: - **Description:** Disables a specific subagent. - **Usage:** `/agents disable ` - **`config`**: - **Description:** Opens a configuration dialog for the specified agent to adjust its model, temperature, or execution limits. - **Usage:** `/agents config ` ### `/auth` - **Description:** Open a dialog that lets you change the authentication method. ### `/bug` - **Description:** File an issue about Gemini CLI. By default, the issue is filed within the GitHub repository for Gemini CLI. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `advanced.bugCommand` setting in your `.gemini/settings.json` files. ### `/chat` - **Description:** Alias for `/resume`. Both commands now expose the same session browser action and checkpoint subcommands. - **Menu layout when typing `/chat` (or `/resume`)**: - `-- auto --` - `list` (selecting this opens the auto-saved session browser) - `-- checkpoints --` - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. - **`delete `** - **Description:** Deletes a saved conversation checkpoint. - **Equivalent:** `/resume delete ` - **`list`** - **Description:** Lists available tags for manually saved checkpoints. - **Note:** This command only lists chats saved within the current project. Because chat history is project-scoped, chats saved in other project directories will not be displayed. - **Equivalent:** `/resume list` - **`resume `** - **Description:** Resumes a conversation from a previous save. - **Note:** You can only resume chats that were saved within the current project. To resume a chat from a different project, you must run the Gemini CLI from that project's directory. - **Equivalent:** `/resume resume ` - **`save `** - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. - **Details on checkpoint location:** The default locations for saved chat checkpoints are: - Linux/macOS: `~/.gemini/tmp//` - Windows: `C:\Users\\.gemini\tmp\\` - **Behavior:** Chats are saved into a project-specific directory, determined by where you run the CLI. Consequently, saved chats are only accessible when working within that same project. - **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../cli/checkpointing.md). - **Equivalent:** `/resume save ` - **`share [filename]`** - **Description:** Writes the current conversation to a provided Markdown or JSON file. If no filename is provided, then the CLI will generate one. - **Usage:** `/chat share file.md` or `/chat share file.json`. - **Equivalent:** `/resume share [filename]` ### `/clear` - **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared. - **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action. ### `/commands` - **Description:** Manage custom slash commands loaded from `.toml` files. - **Sub-commands:** - **`reload`**: - **Description:** Reload custom command definitions from all sources (user-level `~/.gemini/commands/`, project-level `/.gemini/commands/`, MCP prompts, and extensions). Use this to pick up new or modified `.toml` files without restarting the CLI. - **Usage:** `/commands reload` ### `/compress` - **Description:** Replace the entire chat context with a summary. This saves on tokens used for future tasks while retaining a high level summary of what has happened. ### `/copy` - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. - **Behavior:** - Local sessions use system clipboard tools (pbcopy/xclip/clip). - Remote sessions (SSH/WSL) use OSC 52 and require terminal support. - **Note:** This command requires platform-specific clipboard tools to be installed. - On Linux, it requires `xclip` or `xsel`. You can typically install them using your system's package manager. - On macOS, it requires `pbcopy`, and on Windows, it requires `clip`. These tools are typically pre-installed on their respective systems. ### `/directory` (or `/dir`) - **Description:** Manage workspace directories for multi-directory support. - **Sub-commands:** - **`add`**: - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well. - **Usage:** `/directory add ,` - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead. - **`show`**: - **Description:** Display all directories added by `/directory add` and `--include-directories`. - **Usage:** `/directory show` ### `/docs` - **Description:** Open the Gemini CLI documentation in your browser. ### `/editor` - **Description:** Open a dialog for selecting supported editors. ### `/extensions` - **Description:** Manage extensions. See [Gemini CLI Extensions](../extensions/index.md). - **Sub-commands:** - **`config`**: - **Description:** Configure extension settings. - **`disable`**: - **Description:** Disable an extension. - **`enable`**: - **Description:** Enable an extension. - **`explore`**: - **Description:** Open extensions page in your browser. - **`install`**: - **Description:** Install an extension from a git repo or local path. - **`link`**: - **Description:** Link an extension from a local path. - **`list`**: - **Description:** List active extensions. - **`restart`**: - **Description:** Restart all extensions. - **`uninstall`**: - **Description:** Uninstall an extension. - **`update`**: - **Description:** Update extensions. Usage: update |--all ### `/help` (or `/?`) - **Description:** Display help information about Gemini CLI, including available commands and their usage. ### `/hooks` - **Description:** Manage hooks, which allow you to intercept and customize Gemini CLI behavior at specific lifecycle events. - **Sub-commands:** - **`disable-all`**: - **Description:** Disable all enabled hooks. - **`disable `**: - **Description:** Disable a hook by name. - **`enable-all`**: - **Description:** Enable all disabled hooks. - **`enable `**: - **Description:** Enable a hook by name. - **`list`** (or `show`, `panel`): - **Description:** Display all registered hooks with their status. ### `/ide` - **Description:** Manage IDE integration. - **Sub-commands:** - **`disable`**: - **Description:** Disable IDE integration. - **`enable`**: - **Description:** Enable IDE integration. - **`install`**: - **Description:** Install required IDE companion. - **`status`**: - **Description:** Check status of IDE integration. ### `/init` - **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent. ### `/mcp` - **Description:** Manage configured Model Context Protocol (MCP) servers. - **Sub-commands:** - **`auth`**: - **Description:** Authenticate with an OAuth-enabled MCP server. - **Usage:** `/mcp auth ` - **Details:** If `` is provided, it initiates the OAuth flow for that server. If no server name is provided, it lists all configured servers that support OAuth authentication. - **`desc`** - **Description:** List configured MCP servers and tools with descriptions. - **`disable`** - **Description:** Disable an MCP server. - **`enable`** - **Description:** Enable a disabled MCP server. - **`list`** or **`ls`**: - **Description:** List configured MCP servers and tools. This is the default action if no subcommand is specified. - **`refresh`**: - **Description:** Restarts all MCP servers and re-discovers their available tools. - **`schema`**: - **Description:** List configured MCP servers and tools with descriptions and schemas. ### `/memory` - **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files). - **Sub-commands:** - **`add`**: - **Description:** Adds the following text to the AI's memory. Usage: `/memory add ` - **`list`**: - **Description:** Lists the paths of the GEMINI.md files in use for hierarchical memory. - **`refresh`**: - **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content. - **`show`**: - **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model. - **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md). ### `/model` - **Description:** Manage model configuration. - **Sub-commands:** - **`manage`**: - **Description:** Opens a dialog to configure the model. - **`set`**: - **Description:** Set the model to use. - **Usage:** `/model set [--persist]` ### `/permissions` - **Description:** Manage folder trust settings and other permissions. - **Sub-commands:** - **`trust`**: - **Description:** Manage folder trust settings. - **Usage:** `/permissions trust []` ### `/plan` - **Description:** Switch to Plan Mode (read-only) and view the current plan if one has been generated. - **Note:** This feature is enabled by default. It can be disabled via the `experimental.plan` setting in your configuration. - **Sub-commands:** - **`copy`**: - **Description:** Copy the currently approved plan to your clipboard. ### `/policies` - **Description:** Manage policies. - **Sub-commands:** - **`list`**: - **Description:** List all active policies grouped by mode. ### `/privacy` - **Description:** Display the Privacy Notice and allow users to select whether they consent to the collection of their data for service improvement purposes. ### `/quit` (or `/exit`) - **Description:** Exit Gemini CLI. ### `/restore` - **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if checkpointing is configured via [settings](./configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. ### `/rewind` - **Description:** Navigates backward through the conversation history, letting you review past interactions and potentially revert both chat state and file changes. - **Usage:** Press **Esc** twice as a shortcut. - **Features:** - **Select Interaction:** Preview user prompts and file changes. - **Action Selection:** Choose to rewind history only, revert code changes only, or both. ### `/resume` - **Description:** Browse and resume previous conversation sessions, and manage manual chat checkpoints. - **Features:** - **Auto sessions:** Run `/resume` to open the interactive session browser for automatically saved conversations. - **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`, `/resume resume`, etc.). - **Management:** Delete unwanted sessions directly from the browser - **Resume:** Select any session to resume and continue the conversation - **Search:** Use `/` to search through conversation content across all sessions - **Session Browser:** Interactive interface showing all saved sessions with timestamps, message counts, and first user message for context - **Sorting:** Sort sessions by date or message count - **Note:** All conversations are automatically saved as you chat - no manual saving required. See [Session Management](../cli/session-management.md) for complete details. - **Alias:** `/chat` provides the same behavior and subcommands. - **Sub-commands:** - **`list`** - **Description:** Lists available tags for manual chat checkpoints. - **`save `** - **Description:** Saves the current conversation as a tagged checkpoint. - **`resume `** (alias: `load`) - **Description:** Loads a previously saved tagged checkpoint. - **`delete `** - **Description:** Deletes a tagged checkpoint. - **`share [filename]`** - **Description:** Exports the current conversation to Markdown or JSON. - **`debug`** - **Description:** Export the most recent API request as JSON payload (nightly builds). - **Compatibility alias:** `/resume checkpoints ...` is still accepted for the same checkpoint commands. ### `/settings` - **Description:** Open the settings editor to view and modify Gemini CLI settings. - **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors. See the [settings documentation](../cli/settings.md) for a full list of available settings. - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while others require a restart. ### `/shells` (or `/bashes`) - **Description:** Toggle the background shells view. This allows you to view and manage long-running processes that you've sent to the background. ### `/setup-github` - **Description:** Set up GitHub Actions to triage issues and review PRs with Gemini. ### `/skills` - **Description:** Manage Agent Skills, which provide on-demand expertise and specialized workflows. - **Sub-commands:** - **`disable `**: - **Description:** Disable a specific skill by name. - **Usage:** `/skills disable ` - **`enable `**: - **Description:** Enable a specific skill by name. - **Usage:** `/skills enable ` - **`list`**: - **Description:** List all discovered skills and their current status (enabled/disabled). - **`reload`**: - **Description:** Refresh the list of discovered skills from all tiers (workspace, user, and extensions). ### `/stats` - **Description:** Display detailed statistics for the current Gemini CLI session. - **Sub-commands:** - **`session`**: - **Description:** Show session-specific usage statistics, including duration, tool calls, and performance metrics. This is the default view. - **`model`**: - **Description:** Show model-specific usage statistics, including token counts and quota information. - **`tools`**: - **Description:** Show tool-specific usage statistics. ### `/terminal-setup` - **Description:** Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf). ### `/theme` - **Description:** Open a dialog that lets you change the visual theme of Gemini CLI. ### `/tools` - **Description:** Display a list of tools that are currently available within Gemini CLI. - **Usage:** `/tools [desc]` - **Sub-commands:** - **`desc`** or **`descriptions`**: - **Description:** Show detailed descriptions of each tool, including each tool's name with its full description as provided to the model. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. ### `/upgrade` - **Description:** Open the Gemini Code Assist upgrade page in your browser. This lets you upgrade your tier for higher usage limits. - **Note:** This command is only available when logged in with Google. ### `/vim` - **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes. - **Features:** - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` - **INSERT mode:** Standard text input with escape to return to NORMAL mode - **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line) - **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions - **Repeat last command:** Use `.` to repeat the last editing operation - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer ### Custom commands Custom commands allow you to create personalized shortcuts for your most-used prompts. For detailed instructions on how to create, manage, and use them, please see the dedicated [Custom Commands documentation](../cli/custom-commands.md). ## Input prompt shortcuts These shortcuts apply directly to the input prompt for text manipulation. - **Undo:** - **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action in the input prompt. - **Redo:** - **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the last undone action in the input prompt. ## At commands (`@`) At commands are used to include the content of files or directories as part of your prompt to Gemini. These commands include git-aware filtering. - **`@`** - **Description:** Inject the content of the specified file or files into your current prompt. This is useful for asking questions about specific code, text, or collections of files. - **Examples:** - `@path/to/your/file.txt Explain this text.` - `@src/my_project/ Summarize the code in this directory.` - `What is this file about? @README.md` - **Details:** - If a path to a single file is provided, the content of that file is read. - If a path to a directory is provided, the command attempts to read the content of files within that directory and any subdirectories. - Spaces in paths should be escaped with a backslash (e.g., `@My\ Documents/file.txt`). - The command uses the `read_many_files` tool internally. The content is fetched and then inserted into your query before being sent to the Gemini model. - **Git-aware filtering:** By default, git-ignored files (like `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can be changed via the `context.fileFiltering` settings. - **File types:** The command is intended for text-based files. While it might attempt to read any file, binary files or very large files might be skipped or truncated by the underlying `read_many_files` tool to ensure performance and relevance. The tool indicates if files were skipped. - **Output:** The CLI will show a tool call message indicating that `read_many_files` was used, along with a message detailing the status and the path(s) that were processed. - **`@` (Lone at symbol)** - **Description:** If you type a lone `@` symbol without a path, the query is passed as-is to the Gemini model. This might be useful if you are specifically talking _about_ the `@` symbol in your prompt. ### Error handling for `@` commands - If the path specified after `@` is not found or is invalid, an error message will be displayed, and the query might not be sent to the Gemini model, or it will be sent without the file content. - If the `read_many_files` tool encounters an error (e.g., permission issues), this will also be reported. ## Shell mode and passthrough commands (`!`) The `!` prefix lets you interact with your system's shell directly from within Gemini CLI. - **`!`** - **Description:** Execute the given `` using `bash` on Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you override `ComSpec`). Any output or errors from the command are displayed in the terminal. - **Examples:** - `!ls -la` (executes `ls -la` and returns to Gemini CLI) - `!git status` (executes `git status` and returns to Gemini CLI) - **`!` (Toggle shell mode)** - **Description:** Typing `!` on its own toggles shell mode. - **Entering shell mode:** - When active, shell mode uses a different coloring and a "Shell Mode Indicator". - While in shell mode, text you type is interpreted directly as a shell command. - **Exiting shell mode:** - When exited, the UI reverts to its standard appearance and normal Gemini CLI behavior resumes. - **Caution for all `!` usage:** Commands you execute in shell mode have the same permissions and impact as if you ran them directly in your terminal. - **Environment variable:** When a command is executed via `!` or in shell mode, the `GEMINI_CLI=1` environment variable is set in the subprocess's environment. This allows scripts or tools to detect if they are being run from within the Gemini CLI. ================================================ FILE: docs/reference/configuration.md ================================================ # Gemini CLI configuration Gemini CLI offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings. ## Configuration layers Configuration is applied in the following order of precedence (lower numbers are overridden by higher numbers): 1. **Default values:** Hardcoded defaults within the application. 2. **System defaults file:** System-wide default settings that can be overridden by other settings files. 3. **User settings file:** Global settings for the current user. 4. **Project settings file:** Project-specific settings. 5. **System settings file:** System-wide settings that override all other settings files. 6. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files. 7. **Command-line arguments:** Values passed when launching the CLI. ## Settings files Gemini CLI uses JSON settings files for persistent configuration. There are four locations for these files: > [!TIP] > JSON-aware editors can use autocomplete and validation by pointing to > the generated schema at `schemas/settings.schema.json` in this repository. > When working outside the repo, reference the hosted schema at > `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. - **System defaults file:** - **Location:** `/etc/gemini-cli/system-defaults.json` (Linux), `C:\ProgramData\gemini-cli\system-defaults.json` (Windows) or `/Library/Application Support/GeminiCli/system-defaults.json` (macOS). The path can be overridden using the `GEMINI_CLI_SYSTEM_DEFAULTS_PATH` environment variable. - **Scope:** Provides a base layer of system-wide default settings. These settings have the lowest precedence and are intended to be overridden by user, project, or system override settings. - **User settings file:** - **Location:** `~/.gemini/settings.json` (where `~` is your home directory). - **Scope:** Applies to all Gemini CLI sessions for the current user. User settings override system defaults. - **Project settings file:** - **Location:** `.gemini/settings.json` within your project's root directory. - **Scope:** Applies only when running Gemini CLI from that specific project. Project settings override user settings and system defaults. - **System settings file:** - **Location:** `/etc/gemini-cli/settings.json` (Linux), `C:\ProgramData\gemini-cli\settings.json` (Windows) or `/Library/Application Support/GeminiCli/settings.json` (macOS). The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable. - **Scope:** Applies to all Gemini CLI sessions on the system, for all users. System settings act as overrides, taking precedence over all other settings files. May be useful for system administrators at enterprises to have controls over users' Gemini CLI setups. **Note on environment variables in settings:** String values within your `settings.json` and `gemini-extension.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. **Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI in a corporate environment, please see the [Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project In addition to a project settings file, a project's `.gemini` directory can contain other project-specific files related to Gemini CLI's operation, such as: - [Custom sandbox profiles](#sandboxing) (e.g., `.gemini/sandbox-macos-custom.sb`, `.gemini/sandbox.Dockerfile`). ### Available settings in `settings.json` Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. #### `policyPaths` - **`policyPaths`** (array): - **Description:** Additional policy files or directories to load. - **Default:** `[]` - **Requires restart:** Yes #### `adminPolicyPaths` - **`adminPolicyPaths`** (array): - **Description:** Additional admin policy files or directories to load. - **Default:** `[]` - **Requires restart:** Yes #### `general` - **`general.preferredEditor`** (string): - **Description:** The preferred editor to open files in. - **Default:** `undefined` - **`general.vimMode`** (boolean): - **Description:** Enable Vim keybindings - **Default:** `false` - **`general.defaultApprovalMode`** (enum): - **Description:** The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). - **Default:** `"default"` - **Values:** `"default"`, `"auto_edit"`, `"plan"` - **`general.devtools`** (boolean): - **Description:** Enable DevTools inspector on launch. - **Default:** `false` - **`general.enableAutoUpdate`** (boolean): - **Description:** Enable automatic updates. - **Default:** `true` - **`general.enableAutoUpdateNotification`** (boolean): - **Description:** Enable update notification prompts. - **Default:** `true` - **`general.enableNotifications`** (boolean): - **Description:** Enable run-event notifications for action-required prompts and session completion. Currently macOS only. - **Default:** `false` - **`general.checkpointing.enabled`** (boolean): - **Description:** Enable session checkpointing for recovery - **Default:** `false` - **Requires restart:** Yes - **`general.plan.directory`** (string): - **Description:** The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. - **Default:** `undefined` - **Requires restart:** Yes - **`general.plan.modelRouting`** (boolean): - **Description:** Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. - **Default:** `true` - **`general.retryFetchErrors`** (boolean): - **Description:** Retry on "exception TypeError: fetch failed sending request" errors. - **Default:** `true` - **`general.maxAttempts`** (number): - **Description:** Maximum number of attempts for requests to the main chat model. Cannot exceed 10. - **Default:** `10` - **`general.debugKeystrokeLogging`** (boolean): - **Description:** Enable debug logging of keystrokes to the console. - **Default:** `false` - **`general.sessionRetention.enabled`** (boolean): - **Description:** Enable automatic session cleanup - **Default:** `true` - **`general.sessionRetention.maxAge`** (string): - **Description:** Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") - **Default:** `"30d"` - **`general.sessionRetention.maxCount`** (number): - **Description:** Alternative: Maximum number of sessions to keep (most recent) - **Default:** `undefined` - **`general.sessionRetention.minRetention`** (string): - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` #### `output` - **`output.format`** (enum): - **Description:** The format of the CLI output. Can be `text` or `json`. - **Default:** `"text"` - **Values:** `"text"`, `"json"` #### `ui` - **`ui.theme`** (string): - **Description:** The color theme for the UI. See the CLI themes guide for available options. - **Default:** `undefined` - **`ui.autoThemeSwitching`** (boolean): - **Description:** Automatically switch between default light and dark themes based on terminal background color. - **Default:** `true` - **`ui.terminalBackgroundPollingInterval`** (number): - **Description:** Interval in seconds to poll the terminal background color. - **Default:** `60` - **`ui.customThemes`** (object): - **Description:** Custom theme definitions. - **Default:** `{}` - **`ui.hideWindowTitle`** (boolean): - **Description:** Hide the window title bar - **Default:** `false` - **Requires restart:** Yes - **`ui.inlineThinkingMode`** (enum): - **Description:** Display model thinking inline: off or full. - **Default:** `"off"` - **Values:** `"off"`, `"full"` - **`ui.showStatusInTitle`** (boolean): - **Description:** Show Gemini CLI model thoughts in the terminal window title during the working phase - **Default:** `false` - **`ui.dynamicWindowTitle`** (boolean): - **Description:** Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) - **Default:** `true` - **`ui.showHomeDirectoryWarning`** (boolean): - **Description:** Show a warning when running Gemini CLI in the home directory. - **Default:** `true` - **Requires restart:** Yes - **`ui.showCompatibilityWarnings`** (boolean): - **Description:** Show warnings about terminal or OS compatibility issues. - **Default:** `true` - **Requires restart:** Yes - **`ui.hideTips`** (boolean): - **Description:** Hide helpful tips in the UI - **Default:** `false` - **`ui.escapePastedAtSymbols`** (boolean): - **Description:** When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. - **Default:** `false` - **`ui.showShortcutsHint`** (boolean): - **Description:** Show the "? for shortcuts" hint above the input. - **Default:** `true` - **`ui.hideBanner`** (boolean): - **Description:** Hide the application banner - **Default:** `false` - **`ui.hideContextSummary`** (boolean): - **Description:** Hide the context summary (GEMINI.md, MCP servers) above the input. - **Default:** `false` - **`ui.footer.items`** (array): - **Description:** List of item IDs to display in the footer. Rendered in order - **Default:** `undefined` - **`ui.footer.showLabels`** (boolean): - **Description:** Display a second line above the footer items with descriptive headers (e.g., /model). - **Default:** `true` - **`ui.footer.hideCWD`** (boolean): - **Description:** Hide the current working directory in the footer. - **Default:** `false` - **`ui.footer.hideSandboxStatus`** (boolean): - **Description:** Hide the sandbox status indicator in the footer. - **Default:** `false` - **`ui.footer.hideModelInfo`** (boolean): - **Description:** Hide the model name and context usage in the footer. - **Default:** `false` - **`ui.footer.hideContextPercentage`** (boolean): - **Description:** Hides the context window usage percentage. - **Default:** `true` - **`ui.hideFooter`** (boolean): - **Description:** Hide the footer from the UI - **Default:** `false` - **`ui.showMemoryUsage`** (boolean): - **Description:** Display memory usage information in the UI - **Default:** `false` - **`ui.showLineNumbers`** (boolean): - **Description:** Show line numbers in the chat. - **Default:** `true` - **`ui.showCitations`** (boolean): - **Description:** Show citations for generated text in the chat. - **Default:** `false` - **`ui.showModelInfoInChat`** (boolean): - **Description:** Show the model name in the chat for each model turn. - **Default:** `false` - **`ui.showUserIdentity`** (boolean): - **Description:** Show the signed-in user's identity (e.g. email) in the UI. - **Default:** `true` - **`ui.useAlternateBuffer`** (boolean): - **Description:** Use an alternate screen buffer for the UI, preserving shell history. - **Default:** `false` - **Requires restart:** Yes - **`ui.useBackgroundColor`** (boolean): - **Description:** Whether to use background colors in the UI. - **Default:** `true` - **`ui.incrementalRendering`** (boolean): - **Description:** Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. - **Default:** `true` - **Requires restart:** Yes - **`ui.showSpinner`** (boolean): - **Description:** Show the spinner during operations. - **Default:** `true` - **`ui.loadingPhrases`** (enum): - **Description:** What to show while the model is working: tips, witty comments, both, or nothing. - **Default:** `"tips"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` - **`ui.errorVerbosity`** (enum): - **Description:** Controls whether recoverable errors are hidden (low) or fully shown (full). - **Default:** `"low"` - **Values:** `"low"`, `"full"` - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. - **Default:** `[]` - **`ui.accessibility.enableLoadingPhrases`** (boolean): - **Description:** @deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations. - **Default:** `true` - **Requires restart:** Yes - **`ui.accessibility.screenReader`** (boolean): - **Description:** Render output in plain-text to be more screen reader accessible - **Default:** `false` - **Requires restart:** Yes #### `ide` - **`ide.enabled`** (boolean): - **Description:** Enable IDE integration mode. - **Default:** `false` - **Requires restart:** Yes - **`ide.hasSeenNudge`** (boolean): - **Description:** Whether the user has seen the IDE integration nudge. - **Default:** `false` #### `privacy` - **`privacy.usageStatisticsEnabled`** (boolean): - **Description:** Enable collection of usage statistics - **Default:** `true` - **Requires restart:** Yes #### `billing` - **`billing.overageStrategy`** (enum): - **Description:** How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. - **Default:** `"ask"` - **Values:** `"ask"`, `"always"`, `"never"` #### `model` - **`model.name`** (string): - **Description:** The Gemini model to use for conversations. - **Default:** `undefined` - **`model.maxSessionTurns`** (number): - **Description:** Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. - **Default:** `-1` - **`model.summarizeToolOutput`** (object): - **Description:** Enables or disables summarization of tool output. Configure per-tool token budgets (for example {"run_shell_command": {"tokenBudget": 2000}}). Currently only the run_shell_command tool supports summarization. - **Default:** `undefined` - **`model.compressionThreshold`** (number): - **Description:** The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). - **Default:** `0.5` - **Requires restart:** Yes - **`model.disableLoopDetection`** (boolean): - **Description:** Disable automatic detection and prevention of infinite loops. - **Default:** `false` - **Requires restart:** Yes - **`model.skipNextSpeakerCheck`** (boolean): - **Description:** Skip the next speaker check. - **Default:** `true` #### `modelConfigs` - **`modelConfigs.aliases`** (object): - **Description:** Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property. - **Default:** ```json { "base": { "modelConfig": { "generateContentConfig": { "temperature": 0, "topP": 1 } } }, "chat-base": { "extends": "base", "modelConfig": { "generateContentConfig": { "thinkingConfig": { "includeThoughts": true }, "temperature": 1, "topP": 0.95, "topK": 64 } } }, "chat-base-2.5": { "extends": "chat-base", "modelConfig": { "generateContentConfig": { "thinkingConfig": { "thinkingBudget": 8192 } } } }, "chat-base-3": { "extends": "chat-base", "modelConfig": { "generateContentConfig": { "thinkingConfig": { "thinkingLevel": "HIGH" } } } }, "gemini-3-pro-preview": { "extends": "chat-base-3", "modelConfig": { "model": "gemini-3-pro-preview" } }, "gemini-3-flash-preview": { "extends": "chat-base-3", "modelConfig": { "model": "gemini-3-flash-preview" } }, "gemini-2.5-pro": { "extends": "chat-base-2.5", "modelConfig": { "model": "gemini-2.5-pro" } }, "gemini-2.5-flash": { "extends": "chat-base-2.5", "modelConfig": { "model": "gemini-2.5-flash" } }, "gemini-2.5-flash-lite": { "extends": "chat-base-2.5", "modelConfig": { "model": "gemini-2.5-flash-lite" } }, "gemini-2.5-flash-base": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash" } }, "gemini-3-flash-base": { "extends": "base", "modelConfig": { "model": "gemini-3-flash-preview" } }, "classifier": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "maxOutputTokens": 1024, "thinkingConfig": { "thinkingBudget": 512 } } } }, "prompt-completion": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "temperature": 0.3, "maxOutputTokens": 16000, "thinkingConfig": { "thinkingBudget": 0 } } } }, "fast-ack-helper": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "temperature": 0.2, "maxOutputTokens": 120, "thinkingConfig": { "thinkingBudget": 0 } } } }, "edit-corrector": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "thinkingConfig": { "thinkingBudget": 0 } } } }, "summarizer-default": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "maxOutputTokens": 2000 } } }, "summarizer-shell": { "extends": "base", "modelConfig": { "model": "gemini-2.5-flash-lite", "generateContentConfig": { "maxOutputTokens": 2000 } } }, "web-search": { "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ { "googleSearch": {} } ] } } }, "web-fetch": { "extends": "gemini-3-flash-base", "modelConfig": { "generateContentConfig": { "tools": [ { "urlContext": {} } ] } } }, "web-fetch-fallback": { "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection": { "extends": "gemini-3-flash-base", "modelConfig": {} }, "loop-detection-double-check": { "extends": "base", "modelConfig": { "model": "gemini-3-pro-preview" } }, "llm-edit-fixer": { "extends": "gemini-3-flash-base", "modelConfig": {} }, "next-speaker-checker": { "extends": "gemini-3-flash-base", "modelConfig": {} }, "chat-compression-3-pro": { "modelConfig": { "model": "gemini-3-pro-preview" } }, "chat-compression-3-flash": { "modelConfig": { "model": "gemini-3-flash-preview" } }, "chat-compression-2.5-pro": { "modelConfig": { "model": "gemini-2.5-pro" } }, "chat-compression-2.5-flash": { "modelConfig": { "model": "gemini-2.5-flash" } }, "chat-compression-2.5-flash-lite": { "modelConfig": { "model": "gemini-2.5-flash-lite" } }, "chat-compression-default": { "modelConfig": { "model": "gemini-3-pro-preview" } } } ``` - **`modelConfigs.customAliases`** (object): - **Description:** Custom named presets for model configs. These are merged with (and override) the built-in aliases. - **Default:** `{}` - **`modelConfigs.customOverrides`** (array): - **Description:** Custom model config overrides. These are merged with (and added to) the built-in overrides. - **Default:** `[]` - **`modelConfigs.overrides`** (array): - **Description:** Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used. - **Default:** `[]` - **`modelConfigs.modelDefinitions`** (object): - **Description:** Registry of model metadata, including tier, family, and features. - **Default:** ```json { "gemini-3.1-flash-lite-preview": { "tier": "flash-lite", "family": "gemini-3", "isPreview": true, "isVisible": true, "features": { "thinking": false, "multimodalToolUse": true } }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", "isPreview": true, "isVisible": true, "features": { "thinking": true, "multimodalToolUse": true } }, "gemini-3.1-pro-preview-customtools": { "tier": "pro", "family": "gemini-3", "isPreview": true, "isVisible": false, "features": { "thinking": true, "multimodalToolUse": true } }, "gemini-3-pro-preview": { "tier": "pro", "family": "gemini-3", "isPreview": true, "isVisible": true, "features": { "thinking": true, "multimodalToolUse": true } }, "gemini-3-flash-preview": { "tier": "flash", "family": "gemini-3", "isPreview": true, "isVisible": true, "features": { "thinking": false, "multimodalToolUse": true } }, "gemini-2.5-pro": { "tier": "pro", "family": "gemini-2.5", "isPreview": false, "isVisible": true, "features": { "thinking": false, "multimodalToolUse": false } }, "gemini-2.5-flash": { "tier": "flash", "family": "gemini-2.5", "isPreview": false, "isVisible": true, "features": { "thinking": false, "multimodalToolUse": false } }, "gemini-2.5-flash-lite": { "tier": "flash-lite", "family": "gemini-2.5", "isPreview": false, "isVisible": true, "features": { "thinking": false, "multimodalToolUse": false } }, "auto": { "tier": "auto", "isPreview": true, "isVisible": false, "features": { "thinking": true, "multimodalToolUse": false } }, "pro": { "tier": "pro", "isPreview": false, "isVisible": false, "features": { "thinking": true, "multimodalToolUse": false } }, "flash": { "tier": "flash", "isPreview": false, "isVisible": false, "features": { "thinking": false, "multimodalToolUse": false } }, "flash-lite": { "tier": "flash-lite", "isPreview": false, "isVisible": false, "features": { "thinking": false, "multimodalToolUse": false } }, "auto-gemini-3": { "displayName": "Auto (Gemini 3)", "tier": "auto", "isPreview": true, "isVisible": true, "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false } }, "auto-gemini-2.5": { "displayName": "Auto (Gemini 2.5)", "tier": "auto", "isPreview": false, "isVisible": true, "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash", "features": { "thinking": false, "multimodalToolUse": false } } } ``` - **Requires restart:** Yes - **`modelConfigs.modelIdResolutions`** (object): - **Description:** Rules for resolving requested model names to concrete model IDs based on context. - **Default:** ```json { "gemini-3.1-pro-preview": { "default": "gemini-3.1-pro-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" } ] }, "gemini-3.1-pro-preview-customtools": { "default": "gemini-3.1-pro-preview-customtools", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" } ] }, "gemini-3-flash-preview": { "default": "gemini-3-flash-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-flash" } ] }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" }, { "condition": { "useGemini3_1": true, "useCustomTools": true }, "target": "gemini-3.1-pro-preview-customtools" }, { "condition": { "useGemini3_1": true }, "target": "gemini-3.1-pro-preview" } ] }, "auto-gemini-3": { "default": "gemini-3-pro-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" }, { "condition": { "useGemini3_1": true, "useCustomTools": true }, "target": "gemini-3.1-pro-preview-customtools" }, { "condition": { "useGemini3_1": true }, "target": "gemini-3.1-pro-preview" } ] }, "auto": { "default": "gemini-3-pro-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" }, { "condition": { "useGemini3_1": true, "useCustomTools": true }, "target": "gemini-3.1-pro-preview-customtools" }, { "condition": { "useGemini3_1": true }, "target": "gemini-3.1-pro-preview" } ] }, "pro": { "default": "gemini-3-pro-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-pro" }, { "condition": { "useGemini3_1": true, "useCustomTools": true }, "target": "gemini-3.1-pro-preview-customtools" }, { "condition": { "useGemini3_1": true }, "target": "gemini-3.1-pro-preview" } ] }, "auto-gemini-2.5": { "default": "gemini-2.5-pro" }, "flash": { "default": "gemini-3-flash-preview", "contexts": [ { "condition": { "hasAccessToPreview": false }, "target": "gemini-2.5-flash" } ] }, "flash-lite": { "default": "gemini-2.5-flash-lite" } } ``` - **Requires restart:** Yes - **`modelConfigs.classifierIdResolutions`** (object): - **Description:** Rules for resolving classifier tiers (flash, pro) to concrete model IDs. - **Default:** ```json { "flash": { "default": "gemini-3-flash-preview", "contexts": [ { "condition": { "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] }, "target": "gemini-2.5-flash" }, { "condition": { "requestedModels": ["auto-gemini-3", "gemini-3-pro-preview"] }, "target": "gemini-3-flash-preview" } ] }, "pro": { "default": "gemini-3-pro-preview", "contexts": [ { "condition": { "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] }, "target": "gemini-2.5-pro" }, { "condition": { "useGemini3_1": true, "useCustomTools": true }, "target": "gemini-3.1-pro-preview-customtools" }, { "condition": { "useGemini3_1": true }, "target": "gemini-3.1-pro-preview" } ] } } ``` - **Requires restart:** Yes - **`modelConfigs.modelChains`** (object): - **Description:** Availability policy chains defining fallback behavior for models. - **Default:** ```json { "preview": [ { "model": "gemini-3-pro-preview", "actions": { "terminal": "prompt", "transient": "prompt", "not_found": "prompt", "unknown": "prompt" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } }, { "model": "gemini-3-flash-preview", "isLastResort": true, "actions": { "terminal": "prompt", "transient": "prompt", "not_found": "prompt", "unknown": "prompt" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } } ], "default": [ { "model": "gemini-2.5-pro", "actions": { "terminal": "prompt", "transient": "prompt", "not_found": "prompt", "unknown": "prompt" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } }, { "model": "gemini-2.5-flash", "isLastResort": true, "actions": { "terminal": "prompt", "transient": "prompt", "not_found": "prompt", "unknown": "prompt" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } } ], "lite": [ { "model": "gemini-2.5-flash-lite", "actions": { "terminal": "silent", "transient": "silent", "not_found": "silent", "unknown": "silent" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } }, { "model": "gemini-2.5-flash", "actions": { "terminal": "silent", "transient": "silent", "not_found": "silent", "unknown": "silent" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } }, { "model": "gemini-2.5-pro", "isLastResort": true, "actions": { "terminal": "silent", "transient": "silent", "not_found": "silent", "unknown": "silent" }, "stateTransitions": { "terminal": "terminal", "transient": "terminal", "not_found": "terminal", "unknown": "terminal" } } ] } ``` - **Requires restart:** Yes #### `agents` - **`agents.overrides`** (object): - **Description:** Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config. - **Default:** `{}` - **Requires restart:** Yes - **`agents.browser.sessionMode`** (enum): - **Description:** Session mode: 'persistent', 'isolated', or 'existing'. - **Default:** `"persistent"` - **Values:** `"persistent"`, `"isolated"`, `"existing"` - **Requires restart:** Yes - **`agents.browser.headless`** (boolean): - **Description:** Run browser in headless mode. - **Default:** `false` - **Requires restart:** Yes - **`agents.browser.profilePath`** (string): - **Description:** Path to browser profile directory for session persistence. - **Default:** `undefined` - **Requires restart:** Yes - **`agents.browser.visualModel`** (string): - **Description:** Model override for the visual agent. - **Default:** `undefined` - **Requires restart:** Yes - **`agents.browser.allowedDomains`** (array): - **Description:** A list of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). - **Default:** ```json ["github.com", "*.google.com", "localhost"] ``` - **Requires restart:** Yes - **`agents.browser.disableUserInput`** (boolean): - **Description:** Disable user input on browser window during automation. - **Default:** `true` #### `context` - **`context.fileName`** (string | string[]): - **Description:** The name of the context file or files to load into memory. Accepts either a single string or an array of strings. - **Default:** `undefined` - **`context.importFormat`** (string): - **Description:** The format to use when importing memory. - **Default:** `undefined` - **`context.includeDirectoryTree`** (boolean): - **Description:** Whether to include the directory tree of the current working directory in the initial request to the model. - **Default:** `true` - **`context.discoveryMaxDirs`** (number): - **Description:** Maximum number of directories to search for memory. - **Default:** `200` - **`context.includeDirectories`** (array): - **Description:** Additional directories to include in the workspace context. Missing directories will be skipped with a warning. - **Default:** `[]` - **`context.loadMemoryFromIncludeDirectories`** (boolean): - **Description:** Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. - **Default:** `false` - **`context.fileFiltering.respectGitIgnore`** (boolean): - **Description:** Respect .gitignore files when searching. - **Default:** `true` - **Requires restart:** Yes - **`context.fileFiltering.respectGeminiIgnore`** (boolean): - **Description:** Respect .geminiignore files when searching. - **Default:** `true` - **Requires restart:** Yes - **`context.fileFiltering.enableRecursiveFileSearch`** (boolean): - **Description:** Enable recursive file search functionality when completing @ references in the prompt. - **Default:** `true` - **Requires restart:** Yes - **`context.fileFiltering.enableFuzzySearch`** (boolean): - **Description:** Enable fuzzy search when searching for files. - **Default:** `true` - **Requires restart:** Yes - **`context.fileFiltering.customIgnoreFilePaths`** (array): - **Description:** Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. - **Default:** `[]` - **Requires restart:** Yes #### `tools` - **`tools.sandbox`** (string): - **Description:** Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes - **`tools.sandboxAllowedPaths`** (array): - **Description:** List of additional paths that the sandbox is allowed to access. - **Default:** `[]` - **Requires restart:** Yes - **`tools.sandboxNetworkAccess`** (boolean): - **Description:** Whether the sandbox is allowed to access the network. - **Default:** `false` - **Requires restart:** Yes - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. - **Default:** `true` - **Requires restart:** Yes - **`tools.shell.pager`** (string): - **Description:** The pager command to use for shell output. Defaults to `cat`. - **Default:** `"cat"` - **`tools.shell.showColor`** (boolean): - **Description:** Show color in shell output. - **Default:** `false` - **`tools.shell.inactivityTimeout`** (number): - **Description:** The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes. - **Default:** `300` - **`tools.shell.enableShellOutputEfficiency`** (boolean): - **Description:** Enable shell output efficiency optimizations for better performance. - **Default:** `true` - **`tools.core`** (array): - **Description:** Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for available names. - **Default:** `undefined` - **Requires restart:** Yes - **`tools.allowed`** (array): - **Description:** Tool names that bypass the confirmation dialog. Useful for trusted commands (for example ["run_shell_command(git)", "run_shell_command(npm test)"]). See shell tool command restrictions for matching details. - **Default:** `undefined` - **Requires restart:** Yes - **`tools.exclude`** (array): - **Description:** Tool names to exclude from discovery. - **Default:** `undefined` - **Requires restart:** Yes - **`tools.discoveryCommand`** (string): - **Description:** Command to run for tool discovery. - **Default:** `undefined` - **Requires restart:** Yes - **`tools.callCommand`** (string): - **Description:** Defines a custom shell command for invoking discovered tools. The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout. - **Default:** `undefined` - **Requires restart:** Yes - **`tools.useRipgrep`** (boolean): - **Description:** Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. - **Default:** `true` - **`tools.truncateToolOutputThreshold`** (number): - **Description:** Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. - **Default:** `40000` - **Requires restart:** Yes - **`tools.disableLLMCorrection`** (boolean): - **Description:** Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. - **Default:** `true` - **Requires restart:** Yes #### `mcp` - **`mcp.serverCommand`** (string): - **Description:** Command to start an MCP server. - **Default:** `undefined` - **Requires restart:** Yes - **`mcp.allowed`** (array): - **Description:** A list of MCP servers to allow. - **Default:** `undefined` - **Requires restart:** Yes - **`mcp.excluded`** (array): - **Description:** A list of MCP servers to exclude. - **Default:** `undefined` - **Requires restart:** Yes #### `useWriteTodos` - **`useWriteTodos`** (boolean): - **Description:** Enable the write_todos tool. - **Default:** `true` #### `security` - **`security.toolSandboxing`** (boolean): - **Description:** Experimental tool-level sandboxing (implementation in progress). - **Default:** `false` - **`security.disableYoloMode`** (boolean): - **Description:** Disable YOLO mode, even if enabled by a flag. - **Default:** `false` - **Requires restart:** Yes - **`security.disableAlwaysAllow`** (boolean): - **Description:** Disable "Always allow" options in tool confirmation dialogs. - **Default:** `false` - **Requires restart:** Yes - **`security.enablePermanentToolApproval`** (boolean): - **Description:** Enable the "Allow for all future sessions" option in tool confirmation dialogs. - **Default:** `false` - **`security.autoAddToPolicyByDefault`** (boolean): - **Description:** When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. - **Default:** `false` - **`security.blockGitExtensions`** (boolean): - **Description:** Blocks installing and loading extensions from Git. - **Default:** `false` - **Requires restart:** Yes - **`security.allowedExtensions`** (array): - **Description:** List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. - **Default:** `[]` - **Requires restart:** Yes - **`security.folderTrust.enabled`** (boolean): - **Description:** Setting to track whether Folder trust is enabled. - **Default:** `true` - **Requires restart:** Yes - **`security.environmentVariableRedaction.allowed`** (array): - **Description:** Environment variables to always allow (bypass redaction). - **Default:** `[]` - **Requires restart:** Yes - **`security.environmentVariableRedaction.blocked`** (array): - **Description:** Environment variables to always redact. - **Default:** `[]` - **Requires restart:** Yes - **`security.environmentVariableRedaction.enabled`** (boolean): - **Description:** Enable redaction of environment variables that may contain secrets. - **Default:** `false` - **Requires restart:** Yes - **`security.auth.selectedType`** (string): - **Description:** The currently selected authentication type. - **Default:** `undefined` - **Requires restart:** Yes - **`security.auth.enforcedType`** (string): - **Description:** The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate. - **Default:** `undefined` - **Requires restart:** Yes - **`security.auth.useExternal`** (boolean): - **Description:** Whether to use an external authentication flow. - **Default:** `undefined` - **Requires restart:** Yes - **`security.enableConseca`** (boolean): - **Description:** Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. - **Default:** `false` - **Requires restart:** Yes #### `advanced` - **`advanced.autoConfigureMemory`** (boolean): - **Description:** Automatically configure Node.js memory limits - **Default:** `false` - **Requires restart:** Yes - **`advanced.dnsResolutionOrder`** (string): - **Description:** The DNS resolution order. - **Default:** `undefined` - **Requires restart:** Yes - **`advanced.excludedEnvVars`** (array): - **Description:** Environment variables to exclude from project context. - **Default:** ```json ["DEBUG", "DEBUG_MODE"] ``` - **`advanced.bugCommand`** (object): - **Description:** Configuration for the bug report command. - **Default:** `undefined` #### `experimental` - **`experimental.toolOutputMasking.enabled`** (boolean): - **Description:** Enables tool output masking to save tokens. - **Default:** `true` - **Requires restart:** Yes - **`experimental.toolOutputMasking.toolProtectionThreshold`** (number): - **Description:** Minimum number of tokens to protect from masking (most recent tool outputs). - **Default:** `50000` - **Requires restart:** Yes - **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number): - **Description:** Minimum prunable tokens required to trigger a masking pass. - **Default:** `30000` - **Requires restart:** Yes - **`experimental.toolOutputMasking.protectLatestTurn`** (boolean): - **Description:** Ensures the absolute latest turn is never masked, regardless of token count. - **Default:** `true` - **Requires restart:** Yes - **`experimental.enableAgents`** (boolean): - **Description:** Enable local and remote subagents. - **Default:** `true` - **Requires restart:** Yes - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` - **Requires restart:** Yes - **`experimental.extensionConfig`** (boolean): - **Description:** Enable requesting and fetching of extension settings. - **Default:** `true` - **Requires restart:** Yes - **`experimental.extensionRegistry`** (boolean): - **Description:** Enable extension registry explore UI. - **Default:** `false` - **Requires restart:** Yes - **`experimental.extensionRegistryURI`** (string): - **Description:** The URI (web URL or local file path) of the extension registry. - **Default:** `"https://geminicli.com/extensions.json"` - **Requires restart:** Yes - **`experimental.extensionReloading`** (boolean): - **Description:** Enables extension loading/unloading within the CLI session. - **Default:** `false` - **Requires restart:** Yes - **`experimental.jitContext`** (boolean): - **Description:** Enable Just-In-Time (JIT) context loading. - **Default:** `true` - **Requires restart:** Yes - **`experimental.useOSC52Paste`** (boolean): - **Description:** Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). - **Default:** `false` - **`experimental.useOSC52Copy`** (boolean): - **Description:** Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). - **Default:** `false` - **`experimental.plan`** (boolean): - **Description:** Enable Plan Mode. - **Default:** `true` - **Requires restart:** Yes - **`experimental.taskTracker`** (boolean): - **Description:** Enable task tracker tools. - **Default:** `false` - **Requires restart:** Yes - **`experimental.modelSteering`** (boolean): - **Description:** Enable model steering (user hints) to guide the model during tool execution. - **Default:** `false` - **`experimental.directWebFetch`** (boolean): - **Description:** Enable web fetch behavior that bypasses LLM summarization. - **Default:** `false` - **Requires restart:** Yes - **`experimental.dynamicModelConfiguration`** (boolean): - **Description:** Enable dynamic model configuration (definitions, resolutions, and chains) via settings. - **Default:** `false` - **Requires restart:** Yes - **`experimental.gemmaModelRouter.enabled`** (boolean): - **Description:** Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. - **Default:** `false` - **Requires restart:** Yes - **`experimental.gemmaModelRouter.classifier.host`** (string): - **Description:** The host of the classifier. - **Default:** `"http://localhost:9379"` - **Requires restart:** Yes - **`experimental.gemmaModelRouter.classifier.model`** (string): - **Description:** The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes - **`experimental.memoryManager`** (boolean): - **Description:** Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. - **Default:** `false` - **Requires restart:** Yes - **`experimental.topicUpdateNarration`** (boolean): - **Description:** Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. - **Default:** `false` #### `skills` - **`skills.enabled`** (boolean): - **Description:** Enable Agent Skills. - **Default:** `true` - **Requires restart:** Yes - **`skills.disabled`** (array): - **Description:** List of disabled skills. - **Default:** `[]` - **Requires restart:** Yes #### `hooksConfig` - **`hooksConfig.enabled`** (boolean): - **Description:** Canonical toggle for the hooks system. When disabled, no hooks will be executed. - **Default:** `true` - **Requires restart:** Yes - **`hooksConfig.disabled`** (array): - **Description:** List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured. - **Default:** `[]` - **`hooksConfig.notifications`** (boolean): - **Description:** Show visual indicators when hooks are executing. - **Default:** `true` #### `hooks` - **`hooks.BeforeTool`** (array): - **Description:** Hooks that execute before tool execution. Can intercept, validate, or modify tool calls. - **Default:** `[]` - **`hooks.AfterTool`** (array): - **Description:** Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions. - **Default:** `[]` - **`hooks.BeforeAgent`** (array): - **Description:** Hooks that execute before agent loop starts. Can set up context or initialize resources. - **Default:** `[]` - **`hooks.AfterAgent`** (array): - **Description:** Hooks that execute after agent loop completes. Can perform cleanup or summarize results. - **Default:** `[]` - **`hooks.Notification`** (array): - **Description:** Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions. - **Default:** `[]` - **`hooks.SessionStart`** (array): - **Description:** Hooks that execute when a session starts. Can initialize session-specific resources or state. - **Default:** `[]` - **`hooks.SessionEnd`** (array): - **Description:** Hooks that execute when a session ends. Can perform cleanup or persist session data. - **Default:** `[]` - **`hooks.PreCompress`** (array): - **Description:** Hooks that execute before chat history compression. Can back up or analyze conversation before compression. - **Default:** `[]` - **`hooks.BeforeModel`** (array): - **Description:** Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters. - **Default:** `[]` - **`hooks.AfterModel`** (array): - **Description:** Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions. - **Default:** `[]` - **`hooks.BeforeToolSelection`** (array): - **Description:** Hooks that execute before tool selection. Can filter or prioritize available tools dynamically. - **Default:** `[]` #### `admin` - **`admin.secureModeEnabled`** (boolean): - **Description:** If true, disallows YOLO mode and "Always allow" options from being used. - **Default:** `false` - **`admin.extensions.enabled`** (boolean): - **Description:** If false, disallows extensions from being installed or used. - **Default:** `true` - **`admin.mcp.enabled`** (boolean): - **Description:** If false, disallows MCP servers from being used. - **Default:** `true` - **`admin.mcp.config`** (object): - **Description:** Admin-configured MCP servers (allowlist). - **Default:** `{}` - **`admin.mcp.requiredConfig`** (object): - **Description:** Admin-required MCP servers that are always injected. - **Default:** `{}` - **`admin.skills.enabled`** (boolean): - **Description:** If false, disallows agent skills from being used. - **Default:** `true` #### `mcpServers` Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Gemini CLI attempts to connect to each configured MCP server to discover available tools. Every discovered tool is prepended with the `mcp_` prefix and its server alias to form a fully qualified name (FQN) (e.g., `mcp_serverAlias_actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. > [!WARNING] > Avoid using underscores (`_`) in your server aliases (e.g., use > `my-server` instead of `my_server`). The underlying policy engine parses Fully > Qualified Names (`mcp_server_tool`) using the first underscore after the > `mcp_` prefix. An underscore in your server alias will cause the parser to > misidentify the server name, which can cause security policies to fail > silently. - **`mcpServers.`** (object): The server parameters for the named server. - `command` (string, optional): The command to execute to start the MCP server via standard I/O. - `args` (array of strings, optional): Arguments to pass to the command. - `env` (object, optional): Environment variables to set for the server process. - `cwd` (string, optional): The working directory in which to start the server. - `url` (string, optional): The URL of an MCP server that uses Server-Sent Events (SSE) for communication. - `httpUrl` (string, optional): The URL of an MCP server that uses streamable HTTP for communication. - `headers` (object, optional): A map of HTTP headers to send with requests to `url` or `httpUrl`. - `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server. - `trust` (boolean, optional): Trust this server and bypass all tool call confirmations. - `description` (string, optional): A brief description of the server, which may be used for display purposes. - `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. - `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. #### `telemetry` Configures logging and metrics collection for Gemini CLI. For more information, see [Telemetry](../cli/telemetry.md). - **Properties:** - **`enabled`** (boolean): Whether or not telemetry is enabled. - **`target`** (string): The destination for collected telemetry. Supported values are `local` and `gcp`. - **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter. - **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or `http`). - **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs. - **`outfile`** (string): The file to write telemetry to when `target` is `local`. - **`useCollector`** (boolean): Whether to use an external OTLP collector. ### Example `settings.json` Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0: ```json { "general": { "vimMode": true, "preferredEditor": "code", "sessionRetention": { "enabled": true, "maxAge": "30d", "maxCount": 100 } }, "ui": { "theme": "GitHub", "hideBanner": true, "hideTips": false, "customWittyPhrases": [ "You forget a thousand things every day. Make sure this is one of ’em", "Connecting to AGI" ] }, "tools": { "sandbox": "docker", "discoveryCommand": "bin/get_tools", "callCommand": "bin/call_tool", "exclude": ["write_file"] }, "mcpServers": { "mainServer": { "command": "bin/mcp_server.py" }, "anotherServer": { "command": "node", "args": ["mcp_server.js", "--verbose"] } }, "telemetry": { "enabled": true, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true }, "privacy": { "usageStatisticsEnabled": true }, "model": { "name": "gemini-1.5-pro-latest", "maxSessionTurns": 10, "summarizeToolOutput": { "run_shell_command": { "tokenBudget": 100 } } }, "context": { "fileName": ["CONTEXT.md", "GEMINI.md"], "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], "loadFromIncludeDirectories": true, "fileFiltering": { "respectGitIgnore": false } }, "advanced": { "excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] } } ``` ## Shell history The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. - **Location:** `~/.gemini/tmp//shell_history` - `` is a unique identifier generated from your project's root path. - The history is stored in a file named `shell_history`. ## Environment variables and `.env` files Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the [Authentication documentation](../get-started/authentication.md) which covers all available authentication methods. The CLI automatically loads environment variables from an `.env` file. The loading order is: 1. `.env` file in the current working directory. 2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory. 3. If still not found, it looks for `~/.env` (in the user's home directory). **Environment variable exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_API_KEY`**: - Your API key for the Gemini API. - One of several available [authentication methods](../get-started/authentication.md). - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. - **`GEMINI_MODEL`**: - Specifies the default Gemini model to use. - Overrides the hardcoded default - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: `$env:GEMINI_MODEL="gemini-3-flash-preview"`) - **`GEMINI_CLI_IDE_PID`**: - Manually specifies the PID of the IDE process to use for integration. This is useful when running Gemini CLI in a standalone terminal while still wanting to associate it with a specific IDE instance. - Overrides the automatic IDE detection logic. - **`GEMINI_CLI_HOME`**: - Specifies the root directory for Gemini CLI's user-level configuration and storage. - By default, this is the user's system home directory. The CLI will create a `.gemini` folder inside this directory. - Useful for shared compute environments or keeping CLI state isolated. - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`) - **`GEMINI_CLI_SURFACE`**: - Specifies a custom label to include in the `User-Agent` header for API traffic reporting. - This is useful for tracking specific internal tools or distribution channels. - Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell: `$env:GEMINI_CLI_SURFACE="my-custom-tool"`) - **`GOOGLE_API_KEY`**: - Your Google Cloud API key. - Required for using Vertex AI in express mode. - Ensure you have the necessary permissions. - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"` (Windows PowerShell: `$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`). - **`GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID. - Required for using Code Assist or Vertex AI. - If using Vertex AI, ensure you have the necessary permissions in this project. - **Cloud Shell note:** When running in a Cloud Shell environment, this variable defaults to a special project allocated for Cloud Shell users. If you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud Shell, it will be overridden by this default. To use a different project in Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file. - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows PowerShell: `$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GOOGLE_APPLICATION_CREDENTIALS`** (string): - **Description:** The path to your Google Application Credentials JSON file. - **Example:** `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` (Windows PowerShell: `$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\credentials.json"`) - **`GOOGLE_GENAI_API_VERSION`**: - Specifies the API version to use for Gemini API requests. - When set, overrides the default API version used by the SDK. - Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell: `$env:GOOGLE_GENAI_API_VERSION="v1"`) - **`OTLP_GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID for Telemetry in Google Cloud - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows PowerShell: `$env:OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GEMINI_TELEMETRY_ENABLED`**: - Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. - Overrides the `telemetry.enabled` setting. - **`GEMINI_TELEMETRY_TARGET`**: - Sets the telemetry target (`local` or `gcp`). - Overrides the `telemetry.target` setting. - **`GEMINI_TELEMETRY_OTLP_ENDPOINT`**: - Sets the OTLP endpoint for telemetry. - Overrides the `telemetry.otlpEndpoint` setting. - **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**: - Sets the OTLP protocol (`grpc` or `http`). - Overrides the `telemetry.otlpProtocol` setting. - **`GEMINI_TELEMETRY_LOG_PROMPTS`**: - Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. - Overrides the `telemetry.logPrompts` setting. - **`GEMINI_TELEMETRY_OUTFILE`**: - Sets the file path to write telemetry to when the target is `local`. - Overrides the `telemetry.outfile` setting. - **`GEMINI_TELEMETRY_USE_COLLECTOR`**: - Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. - Overrides the `telemetry.useCollector` setting. - **`GOOGLE_CLOUD_LOCATION`**: - Your Google Cloud Project Location (e.g., us-central1). - Required for using Vertex AI in non-express mode. - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"` (Windows PowerShell: `$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`). - **`GEMINI_SANDBOX`**: - Alternative to the `sandbox` setting in `settings.json`. - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. - **`GEMINI_SYSTEM_MD`**: - Replaces the built‑in system prompt with content from a Markdown file. - `true`/`1`: Use project default path `./.gemini/system.md`. - Any other string: Treat as a path (relative/absolute supported, `~` expands). - `false`/`0` or unset: Use the built‑in prompt. See [System Prompt Override](../cli/system-prompt.md). - **`GEMINI_WRITE_SYSTEM_MD`**: - Writes the current built‑in system prompt to a file for review. - `true`/`1`: Write to `./.gemini/system.md`. Otherwise treat the value as a path. - Run the CLI once with this set to generate the file. - **`SEATBELT_PROFILE`** (macOS specific): - Switches the Seatbelt (`sandbox-exec`) profile on macOS. - `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. - `restrictive-open`: Declines operations by default, allows network. - `strict-open`: Restricts both reads and writes to the working directory, allows network. - `strict-proxied`: Same as `strict-open` but routes network through proxy. - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.gemini/` directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). - **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. - **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically. - **`NO_COLOR`**: - Set to any value to disable all color output in the CLI. - **`CLI_TITLE`**: - Set to a string to customize the title of the CLI. - **`CODE_ASSIST_ENDPOINT`**: - Specifies the endpoint for the code assist server. - This is useful for development and testing. ### Environment variable redaction To prevent accidental leakage of sensitive information, Gemini CLI automatically redacts potential secrets from environment variables when executing tools (such as shell commands). This "best effort" redaction applies to variables inherited from the system or loaded from `.env` files. **Default Redaction Rules:** - **By Name:** Variables are redacted if their names contain sensitive terms like `TOKEN`, `SECRET`, `PASSWORD`, `KEY`, `AUTH`, `CREDENTIAL`, `PRIVATE`, or `CERT`. - **By Value:** Variables are redacted if their values match known secret patterns, such as: - Private keys (RSA, OpenSSH, PGP, etc.) - Certificates - URLs containing credentials - API keys and tokens (GitHub, Google, AWS, Stripe, Slack, etc.) - **Specific Blocklist:** Certain variables like `CLIENT_ID`, `DB_URI`, `DATABASE_URL`, and `CONNECTION_STRING` are always redacted by default. **Allowlist (Never Redacted):** - Common system variables (e.g., `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `LANG`). - Variables starting with `GEMINI_CLI_`. - GitHub Action specific variables. **Configuration:** You can customize this behavior in your `settings.json` file: - **`security.allowedEnvironmentVariables`**: A list of variable names to _never_ redact, even if they match sensitive patterns. - **`security.blockedEnvironmentVariables`**: A list of variable names to _always_ redact, even if they don't match sensitive patterns. ```json { "security": { "allowedEnvironmentVariables": ["MY_PUBLIC_KEY", "NOT_A_SECRET_TOKEN"], "blockedEnvironmentVariables": ["INTERNAL_IP_ADDRESS"] } } ``` ## Command-line arguments Arguments passed directly when running the CLI can override other configurations for that specific session. - **`--model `** (**`-m `**): - Specifies the Gemini model to use for this session. - Example: `npm start -- --model gemini-3-pro-preview` - **`--prompt `** (**`-p `**): - **Deprecated:** Use positional arguments instead. - Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode. - **`--prompt-interactive `** (**`-i `**): - Starts an interactive session with the provided prompt as the initial input. - The prompt is processed within the interactive session, not before it. - Cannot be used when piping input from stdin. - Example: `gemini -i "explain this code"` - **`--output-format `**: - **Description:** Specifies the format of the CLI output for non-interactive mode. - **Values:** - `text`: (Default) The standard human-readable output. - `json`: A machine-readable JSON output. - `stream-json`: A streaming JSON output that emits real-time events. - **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. - **`--sandbox`** (**`-s`**): - Enables sandbox mode for this session. - **`--debug`** (**`-d`**): - Enables debug mode for this session, providing more verbose output. Open the debug console with F12 to see the additional logging. - **`--help`** (or **`-h`**): - Displays help information about command-line arguments. - **`--yolo`**: - Enables YOLO mode, which automatically approves all tool calls. - **`--approval-mode `**: - Sets the approval mode for tool calls. Available modes: - `default`: Prompt for approval on each tool call (default behavior) - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) - `plan`: Read-only mode for tool calls (requires experimental planning to be enabled). > **Note:** This mode is currently under development and not yet fully > functional. - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - Example: `gemini --approval-mode auto_edit` - **`--allowed-tools `**: - A comma-separated list of tool names that will bypass the confirmation dialog. - Example: `gemini --allowed-tools "ShellTool(git status)"` - **`--extensions `** (**`-e `**): - Specifies a list of extensions to use for the session. If not provided, all available extensions are used. - Use the special term `gemini -e none` to disable all extensions. - Example: `gemini -e my-extension -e my-other-extension` - **`--list-extensions`** (**`-l`**): - Lists all available extensions and exits. - **`--resume [session_id]`** (**`-r [session_id]`**): - Resume a previous chat session. Use "latest" for the most recent session, provide a session index number, or provide a full session UUID. - If no session_id is provided, defaults to "latest". - Example: `gemini --resume 5` or `gemini --resume latest` or `gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume` - See [Session Management](../cli/session-management.md) for more details. - **`--list-sessions`**: - List all available chat sessions for the current project and exit. - Shows session indices, dates, message counts, and preview of first user message. - Example: `gemini --list-sessions` - **`--delete-session `**: - Delete a specific chat session by its index number or full session UUID. - Use `--list-sessions` first to see available sessions, their indices, and UUIDs. - Example: `gemini --delete-session 3` or `gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890` - **`--include-directories `**: - Includes additional directories in the workspace for multi-directory support. - Can be specified multiple times or as comma-separated values. - 5 directories can be added at maximum. - Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` - **`--screen-reader`**: - Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. - **`--version`**: - Displays the version of the CLI. - **`--experimental-acp`**: - Starts the agent in ACP mode. - **`--allowed-mcp-server-names`**: - Allowed MCP server names. - **`--fake-responses`**: - Path to a file with fake model responses for testing. - **`--record-responses`**: - Path to a file to record model responses for testing. ## Context files (hierarchical instructional context) While not strictly configuration for the CLI's _behavior_, context files (defaulting to `GEMINI.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory") provided to the Gemini model. This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context. - **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. ### Example context file content (e.g., `GEMINI.md`) Here's a conceptual example of what a context file at the root of a TypeScript project might contain: ```markdown # Project: My Awesome TypeScript Library ## General Instructions: - When generating new TypeScript code, please follow the existing coding style. - Ensure all new functions and classes have JSDoc comments. - Prefer functional programming paradigms where appropriate. - All code should be compatible with TypeScript 5.0 and Node.js 20+. ## Coding Style: - Use 2 spaces for indentation. - Interface names should be prefixed with `I` (e.g., `IUserService`). - Private class members should be prefixed with an underscore (`_`). - Always use strict equality (`===` and `!==`). ## Specific Component: `src/api/client.ts` - This file handles all outbound API requests. - When adding new API call functions, ensure they include robust error handling and logging. - Use the existing `fetchWithRetry` utility for all GET requests. ## Regarding Dependencies: - Avoid introducing new external dependencies unless absolutely necessary. - If a new dependency is required, please state the reason. ``` This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context. - **Hierarchical loading and precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: 1. **Global context file:** - Location: `~/.gemini/` (e.g., `~/.gemini/GEMINI.md` in your user home directory). - Scope: Provides default instructions for all your projects. 2. **Project root and ancestors context files:** - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. - Scope: Provides context relevant to the entire project or a significant portion of it. 3. **Sub-directory context files (contextual/local):** - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file. - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. - **Concatenation and UI indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. - **Importing content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](./memport.md). - **Commands for memory management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - See the [Commands documentation](./commands.md#memory) for full details on the `/memory` command and its sub-commands (`show` and `reload`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor the Gemini CLI's responses to your specific needs and projects. ## Sandboxing The Gemini CLI can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. Sandboxing is disabled by default, but you can enable it in a few ways: - Using `--sandbox` or `-s` flag. - Setting `GEMINI_SANDBOX` environment variable. - Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. By default, it uses a pre-built `gemini-cli-sandbox` Docker image. For project-specific sandboxing needs, you can create a custom Dockerfile at `.gemini/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image: ```dockerfile FROM gemini-cli-sandbox # Add your custom dependencies or configurations here # For example: # RUN apt-get update && apt-get install -y some-package # COPY ./my-config /app/my-config ``` When `.gemini/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Gemini CLI to automatically build the custom sandbox image: ```bash BUILD_SANDBOX=1 gemini -s ``` ## Usage statistics To help us improve the Gemini CLI, we collect anonymized usage statistics. This data helps us understand how the CLI is used, identify common issues, and prioritize new features. **What we collect:** - **Tool calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them. - **API requests:** We log the Gemini model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses. - **Session information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode. **What we DON'T collect:** - **Personally identifiable information (PII):** We do not collect any personal information, such as your name, email address, or API keys. - **Prompt and response content:** We do not log the content of your prompts or the responses from the Gemini model. - **File content:** We do not log the content of any files that are read or written by the CLI. **How to opt out:** You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` under the `privacy` category in your `settings.json` file: ```json { "privacy": { "usageStatisticsEnabled": false } } ``` ================================================ FILE: docs/reference/keyboard-shortcuts.md ================================================ # Gemini CLI keyboard shortcuts Gemini CLI ships with a set of default keyboard shortcuts for editing input, navigating history, and controlling the UI. Use this reference to learn the available combinations. #### Basic Controls | Command | Action | Keys | | --------------- | --------------------------------------------------------------- | ------------------- | | `basic.confirm` | Confirm the current selection or choice. | `Enter` | | `basic.cancel` | Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | | `basic.quit` | Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | | `basic.exit` | Exit the CLI when the input buffer is empty. | `Ctrl+D` | #### Cursor Movement | Command | Action | Keys | | ------------------ | ------------------------------------------- | ------------------------------------------ | | `cursor.home` | Move the cursor to the start of the line. | `Ctrl+A`
`Home` | | `cursor.end` | Move the cursor to the end of the line. | `Ctrl+E`
`End` | | `cursor.up` | Move the cursor up one line. | `Up` | | `cursor.down` | Move the cursor down one line. | `Down` | | `cursor.left` | Move the cursor one character to the left. | `Left` | | `cursor.right` | Move the cursor one character to the right. | `Right`
`Ctrl+F` | | `cursor.wordLeft` | Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | | `cursor.wordRight` | Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | #### Editing | Command | Action | Keys | | ---------------------- | ------------------------------------------------ | -------------------------------------------------------- | | `edit.deleteRightAll` | Delete from the cursor to the end of the line. | `Ctrl+K` | | `edit.deleteLeftAll` | Delete from the cursor to the start of the line. | `Ctrl+U` | | `edit.clear` | Clear all text in the input field. | `Ctrl+C` | | `edit.deleteWordLeft` | Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | | `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | | `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` | | `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` | | `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | | `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling | Command | Action | Keys | | ----------------- | ------------------------ | ----------------------------- | | `scroll.up` | Scroll content up. | `Shift+Up` | | `scroll.down` | Scroll content down. | `Shift+Down` | | `scroll.home` | Scroll to the top. | `Ctrl+Home`
`Shift+Home` | | `scroll.end` | Scroll to the bottom. | `Ctrl+End`
`Shift+End` | | `scroll.pageUp` | Scroll up by one page. | `Page Up` | | `scroll.pageDown` | Scroll down by one page. | `Page Down` | #### History & Search | Command | Action | Keys | | ----------------------- | -------------------------------------------- | -------- | | `history.previous` | Show the previous entry in history. | `Ctrl+P` | | `history.next` | Show the next entry in history. | `Ctrl+N` | | `history.search.start` | Start reverse search through history. | `Ctrl+R` | | `history.search.submit` | Submit the selected reverse-search match. | `Enter` | | `history.search.accept` | Accept a suggestion while reverse searching. | `Tab` | #### Navigation | Command | Action | Keys | | --------------------- | -------------------------------------------------- | --------------- | | `nav.up` | Move selection up in lists. | `Up` | | `nav.down` | Move selection down in lists. | `Down` | | `nav.dialog.up` | Move up within dialog options. | `Up`
`K` | | `nav.dialog.down` | Move down within dialog options. | `Down`
`J` | | `nav.dialog.next` | Move to the next item or question in a dialog. | `Tab` | | `nav.dialog.previous` | Move to the previous item or question in a dialog. | `Shift+Tab` | #### Suggestions & Completions | Command | Action | Keys | | ----------------------- | --------------------------------------- | -------------------- | | `suggest.accept` | Accept the inline suggestion. | `Tab`
`Enter` | | `suggest.focusPrevious` | Move to the previous completion option. | `Up`
`Ctrl+P` | | `suggest.focusNext` | Move to the next completion option. | `Down`
`Ctrl+N` | | `suggest.expand` | Expand an inline suggestion. | `Right` | | `suggest.collapse` | Collapse an inline suggestion. | `Left` | #### Text Input | Command | Action | Keys | | -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `input.submit` | Submit the current prompt. | `Enter` | | `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | | `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | | `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls | Command | Action | Keys | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | `app.showErrorDetails` | Toggle detailed error information. | `F12` | | `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` | | `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` | | `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` | | `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | | `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | | `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | | `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | | `app.expandPaste` | Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | | `app.focusShellInput` | Move focus from Gemini to the active shell. | `Tab` | | `app.unfocusShellInput` | Move focus from the shell back to Gemini. | `Shift+Tab` | | `app.clearScreen` | Clear the terminal screen and redraw the UI. | `Ctrl+L` | | `app.restart` | Restart the application. | `R`
`Shift+R` | | `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` | | `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` | #### Background Shell Controls | Command | Action | Keys | | --------------------------- | ------------------------------------------------------------------ | ----------- | | `background.escape` | Dismiss background shell list. | `Esc` | | `background.select` | Confirm selection in background shell list. | `Enter` | | `background.toggle` | Toggle current background shell visibility. | `Ctrl+B` | | `background.toggleList` | Toggle background shell list. | `Ctrl+L` | | `background.kill` | Kill the active background shell. | `Ctrl+K` | | `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` | | `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | | `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | ## Customizing Keybindings You can add alternative keybindings or remove default keybindings by creating a `keybindings.json` file in your home gemini directory (typically `~/.gemini/keybindings.json`). ### Configuration Format The configuration uses a JSON array of objects, similar to VS Code's keybinding schema. Each object must specify a `command` from the reference tables above and a `key` combination. ```json [ { "command": "edit.clear", "key": "cmd+l" }, { // prefix "-" to unbind a key "command": "-app.toggleYolo", "key": "ctrl+y" }, { "command": "input.submit", "key": "ctrl+y" }, { // multiple modifiers "command": "cursor.right", "key": "shift+alt+a" }, { // Some mac keyboards send "Å" instead of "shift+option+a" "command": "cursor.right", "key": "Å" }, { // some base keys have special multi-char names "command": "cursor.right", "key": "shift+pageup" } ] ``` - **Unbinding** To remove an existing or default keybinding, prefix a minus sign (`-`) to the `command` name. - **No Auto-unbinding** The same key can be bound to multiple commands in different contexts at the same time. Therefore, creating a binding does not automatically unbind the key from other commands. - **Explicit Modifiers**: Key matching is explicit. For example, a binding for `ctrl+f` will only trigger on exactly `ctrl+f`, not `ctrl+shift+f` or `alt+ctrl+f`. - **Literal Characters**: Terminals often translate complex key combinations (especially on macOS with the `Option` key) into special characters, losing modifier and keystroke information along the way. For example,`shift+5` might be sent as `%`. In these cases, you must bind to the literal character `%` as bindings to `shift+5` will never fire. To see precisely what is being sent, enable `Debug Keystroke Logging` and hit f12 to open the debug log console. - **Key Modifiers**: The supported key modifiers are: - `ctrl` - `shift`, - `alt` (synonyms: `opt`, `option`) - `cmd` (synonym: `meta`) - **Base Key**: The base key can be any single unicode code point or any of the following special keys: - **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown` - **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, `clear`, `insert`, `printscreen` - **Toggles**: `capslock`, `numlock`, `scrolllock`, `pausebreak` - **Function Keys**: `f1` through `f35` - **Numpad**: `numpad0` through `numpad9`, `numpad_add`, `numpad_subtract`, `numpad_multiply`, `numpad_divide`, `numpad_decimal`, `numpad_separator` ## Additional context-specific shortcuts - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your terminal isn't configured to send Meta with Option. - `!` on an empty prompt: Enter or exit shell mode. - `?` on an empty prompt: Toggle the shortcuts panel above the input. Press `Esc`, `Backspace`, any printable key, or a registered app hotkey to close it. The panel also auto-hides while the agent is running/streaming or when action-required dialogs are shown. Press `?` again to close the panel and insert a `?` into the prompt. - `Tab` + `Tab` (while typing in the prompt): Toggle between minimal and full UI details when no completion/search interaction is active. The selected mode is remembered for future sessions. Full UI remains the default on first run, and single `Tab` keeps its existing completion/focus behavior. - `Shift + Tab` (while typing in the prompt): Cycle approval modes: default, auto-edit, and plan (skipped when agent is busy). - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, otherwise browse and rewind previous interactions. - `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a single-line input, navigate backward or forward through prompt history. - `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to the numbered radio option and confirm when the full number is entered. - `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`) inline when the cursor is over the placeholder. - `Ctrl + X` (while a plan is presented): Open the plan in an external editor to [collaboratively edit or comment](../cli/plan-mode.md#collaborative-plan-editing) on the implementation strategy. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. ## Limitations - On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): - `shift+enter` is only supported in version 1.25 and higher. - `shift+tab` [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) on Node 20 and earlier versions of Node 22. - On macOS's [Terminal](): - `shift+enter` is not supported. ================================================ FILE: docs/reference/memport.md ================================================ # Memory Import Processor The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax. ## Overview This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security. ## Syntax Use the `@` symbol followed by the path to the file you want to import: ```markdown # Main GEMINI.md file This is the main content. @./components/instructions.md More content here. @./shared/configuration.md ``` ## Supported path formats ### Relative paths - `@./file.md` - Import from the same directory - `@../file.md` - Import from parent directory - `@./components/file.md` - Import from subdirectory ### Absolute paths - `@/absolute/path/to/file.md` - Import using absolute path ## Examples ### Basic import ```markdown # My GEMINI.md Welcome to my project! @./get-started.md ## Features @./features/overview.md ``` ### Nested imports The imported files can themselves contain imports, creating a nested structure: ```markdown # main.md @./header.md @./content.md @./footer.md ``` ```markdown # header.md # Project Header @./shared/title.md ``` ## Safety features ### Circular import detection The processor automatically detects and prevents circular imports: ```markdown # file-a.md @./file-b.md ``` ```markdown # file-b.md @./file-a.md ``` ### File access security The `validateImportPath` function ensures that imports are only allowed from specified directories, preventing access to sensitive files outside the allowed scope. ### Maximum import depth To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels). ## Error handling ### Missing files If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output. ### File access errors Permission issues or other file system errors are handled gracefully with appropriate error messages. ## Code region detection The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures. ## Import tree structure The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships. Example tree structure: ``` Memory Files L project: GEMINI.md L a.md L b.md L c.md L d.md L e.md L f.md L included.md ``` The tree preserves the order that files were imported and shows the complete import chain for debugging purposes. ## Comparison to Claude Code's `/memory` (`claude.md`) approach Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed. > [!NOTE] The import tree is mainly for clarity during development and has > limited relevance to LLM consumption. ## API reference ### `processImports(content, basePath, debugMode?, importState?)` Processes import statements in GEMINI.md content. **Parameters:** - `content` (string): The content to process for imports - `basePath` (string): The directory path where the current file is located - `debugMode` (boolean, optional): Whether to enable debug logging (default: false) - `importState` (ImportState, optional): State tracking for circular import prevention **Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree ### `ProcessImportsResult` ```typescript interface ProcessImportsResult { content: string; // The processed content with imports resolved importTree: MemoryFile; // Tree structure showing the import hierarchy } ``` ### `MemoryFile` ```typescript interface MemoryFile { path: string; // The file path imports?: MemoryFile[]; // Direct imports, in the order they were imported } ``` ### `validateImportPath(importPath, basePath, allowedDirectories)` Validates import paths to ensure they are safe and within allowed directories. **Parameters:** - `importPath` (string): The import path to validate - `basePath` (string): The base directory for resolving relative paths - `allowedDirectories` (string[]): Array of allowed directory paths **Returns:** boolean - Whether the import path is valid ### `findProjectRoot(startDir)` Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop. **Parameters:** - `startDir` (string): The directory to start searching from **Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found) ## Best Practices 1. **Use descriptive file names** for imported components 2. **Keep imports shallow** - avoid deeply nested import chains 3. **Document your structure** - maintain a clear hierarchy of imported files 4. **Test your imports** - ensure all referenced files exist and are accessible 5. **Use relative paths** when possible for better portability ## Troubleshooting ### Common issues 1. **Import not working**: Check that the file exists and the path is correct 2. **Circular import warnings**: Review your import structure for circular references 3. **Permission errors**: Ensure the files are readable and within allowed directories 4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly ### Debug mode Enable debug mode to see detailed logging of the import process: ```typescript const result = await processImports(content, basePath, true); ``` ================================================ FILE: docs/reference/policy-engine.md ================================================ # Policy engine The Gemini CLI includes a powerful policy engine that provides fine-grained control over tool execution. It allows users and administrators to define rules that determine whether a tool call should be allowed, denied, or require user confirmation. ## Quick start To create your first policy: 1. **Create the policy directory** if it doesn't exist: **macOS/Linux** ```bash mkdir -p ~/.gemini/policies ``` **Windows (PowerShell)** ```powershell New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\policies" ``` 2. **Create a new policy file** (e.g., `~/.gemini/policies/my-rules.toml`). You can use any filename ending in `.toml`; all such files in this directory will be loaded and combined: ```toml [[rule]] toolName = "run_shell_command" commandPrefix = "git status" decision = "allow" priority = 100 ``` 3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to `git status`). The tool will now execute automatically without prompting for confirmation. ## Core concepts The policy engine operates on a set of rules. Each rule is a combination of conditions and a resulting decision. When a large language model wants to execute a tool, the policy engine evaluates all rules to find the highest-priority rule that matches the tool call. A rule consists of the following main components: - **Conditions**: Criteria that a tool call must meet for the rule to apply. This can include the tool's name, the arguments provided to it, or the current approval mode. - **Decision**: The action to take if the rule matches (`allow`, `deny`, or `ask_user`). - **Priority**: A number that determines the rule's precedence. Higher numbers win. For example, this rule will ask for user confirmation before executing any `git` command. ```toml [[rule]] toolName = "run_shell_command" commandPrefix = "git" decision = "ask_user" priority = 100 ``` ### Conditions Conditions are the criteria that a tool call must meet for a rule to apply. The primary conditions are the tool's name and its arguments. #### Tool Name The `toolName` in the rule must match the name of the tool being called. - **Wildcards**: You can use wildcards to match multiple tools. - `*`: Matches **any tool** (built-in or MCP). - `mcp_server_*`: Matches any tool from a specific MCP server. - `mcp_*_toolName`: Matches a specific tool name across **all** MCP servers. - `mcp_*`: Matches **any tool from any MCP server**. > **Recommendation:** While FQN wildcards are supported, the recommended > approach for MCP tools is to use the `mcpName` field in your TOML rules. See > [Special syntax for MCP tools](#special-syntax-for-mcp-tools). #### Arguments pattern If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. #### Execution environment If `interactive` is specified, the rule will only apply if the CLI's execution environment matches the specified boolean value: - `true`: The rule applies only in interactive mode. - `false`: The rule applies only in non-interactive (headless) mode. If omitted, the rule applies to both interactive and non-interactive environments. ### Decisions There are three possible decisions a rule can enforce: - `allow`: The tool call is executed automatically without user interaction. - `deny`: The tool call is blocked and is not executed. For global rules (those without an `argsPattern`), tools that are denied are **completely excluded from the model's memory**. This means the model will not even see the tool as an option, which is more secure and saves context window space. - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) > [!NOTE] > The `deny` decision is the recommended way to exclude tools. The > legacy `tools.exclude` setting in `settings.json` is deprecated in favor of > policy rules with a `deny` decision. ### Priority system and tiers The policy engine uses a sophisticated priority system to resolve conflicts when multiple rules match a single tool call. The core principle is simple: **the rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. | Tier | Base | Description | | :-------- | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | | Extension | 2 | Policies defined in extensions. | | Workspace | 3 | Policies defined in the current workspace's configuration directory. | | User | 4 | Custom policies defined by the user. | | Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: `final_priority = tier_base + (toml_priority / 1000)` This system guarantees that: - Admin policies always override User, Workspace, and Default policies. - User policies override Workspace and Default policies. - Workspace policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. - A `priority: 10` rule in a Workspace policy policy file becomes `2.010`. - A `priority: 100` rule in a User policy file becomes `3.100`. - A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes Approval modes allow the policy engine to apply different sets of rules based on the CLI's operational mode. A rule can be associated with one or more modes (e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is running in one of its specified modes. If a rule has no modes specified, it is always active. - `default`: The standard interactive mode where most write tools require confirmation. - `autoEdit`: Optimized for automated code editing; some write tools may be auto-approved. - `plan`: A strict, read-only mode for research and design. See [Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies). - `yolo`: A mode where all tools are auto-approved (use with extreme caution). ## Rule matching When a tool call is made, the engine checks it against all active rules, starting from the highest priority. The first rule that matches determines the outcome. A rule matches a tool call if all of its conditions are met: 1. **Tool name**: The `toolName` in the rule must match the name of the tool being called. - **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or `mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for details. 2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. ## Configuration Policies are defined in `.toml` files. The CLI loads these files from Default, User, and (if configured) Admin directories. ### Policy locations | Tier | Type | Location | | :------------ | :----- | :---------------------------------------- | | **User** | Custom | `~/.gemini/policies/*.toml` | | **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | | **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) Administrators can enforce system-wide policies (Tier 4) that override all user and default settings. These policies can be loaded from standard system locations or supplemental paths. ##### Standard Locations These are the default paths the CLI searches for admin policies: | OS | Policy Directory Path | | :---------- | :------------------------------------------------ | | **Linux** | `/etc/gemini-cli/policies` | | **macOS** | `/Library/Application Support/GeminiCli/policies` | | **Windows** | `C:\ProgramData\gemini-cli\policies` | ##### Supplemental Admin Policies Administrators can also specify supplemental policy paths using: - The `--admin-policy` command-line flag. - The `adminPolicyPaths` setting in a system settings file. These supplemental policies are assigned the same **Admin** tier (Base 4) as policies in standard locations. **Security Guard**: Supplemental admin policies are **ignored** if any `.toml` policy files are found in the standard system location. This prevents flag-based overrides when a central system policy has already been established. #### Security Requirements To prevent privilege escalation, the CLI enforces strict security checks on the **standard system policy directory**. If checks fail, the policies in that directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group or others (e.g., `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a security warning, use the folder properties to remove write permissions for non-admin groups. You may need to "Disable inheritance" in Advanced Security Settings. > [!NOTE] > Supplemental admin policies (provided via `--admin-policy` or > `adminPolicyPaths` settings) are **NOT** subject to these strict ownership > checks, as they are explicitly provided by the user or administrator in their > current execution context. ### TOML rule schema Here is a breakdown of the fields available in a TOML policy rule: ```toml [[rule]] # A unique name for the tool, or an array of names. toolName = "run_shell_command" # (Optional) The name of a subagent. If provided, the rule only applies to tool calls # made by this specific subagent. subagent = "generalist" # (Optional) The name of an MCP server. Can be combined with toolName # to form a composite FQN internally like "mcp_mcpName_toolName". mcpName = "my-custom-server" # (Optional) Metadata hints provided by the tool. A rule matches if all # key-value pairs provided here are present in the tool's annotations. toolAnnotations = { readOnlyHint = true } # (Optional) A regex to match against the tool's arguments. argsPattern = '"command":"(git|npm)' # (Optional) A string or array of strings that a shell command must start with. # This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`. commandPrefix = "git" # (Optional) A regex to match against the entire shell command. # This is also syntactic sugar for `toolName = "run_shell_command"`. # Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":""}`). # Because it prepends `"command":"`, it effectively matches from the start of the command. # Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here. # You cannot use commandPrefix and commandRegex in the same rule. commandRegex = "git (commit|push)" # The decision to take. Must be "allow", "deny", or "ask_user". decision = "ask_user" # The priority of the rule, from 0 to 999. priority = 10 # (Optional) A custom message to display when a tool call is denied by this rule. # This message is returned to the model and user, useful for explaining *why* it was denied. deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] # (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. # If omitted, the rule applies to both. interactive = true ``` ### Using arrays (lists) To apply the same rule to multiple tools or command prefixes, you can provide an array of strings for the `toolName` and `commandPrefix` fields. **Example:** This single rule will apply to both the `write_file` and `replace` tools. ```toml [[rule]] toolName = ["write_file", "replace"] decision = "ask_user" priority = 10 ``` ### Special syntax for `run_shell_command` To simplify writing policies for `run_shell_command`, you can use `commandPrefix` or `commandRegex` instead of the more complex `argsPattern`. - `commandPrefix`: Matches if the `command` argument starts with the given string. - `commandRegex`: Matches if the `command` argument matches the given regular expression. **Example:** This rule will ask for user confirmation before executing any `git` command. ```toml [[rule]] toolName = "run_shell_command" commandPrefix = "git" decision = "ask_user" priority = 100 ``` ### Special syntax for MCP tools You can create rules that target tools from Model Context Protocol (MCP) servers using the `mcpName` field. **This is the recommended approach** for defining MCP policies, as it is much more robust than manually writing Fully Qualified Names (FQNs) or string wildcards. > [!WARNING] > Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will > misinterpret the server identity, which can cause wildcard rules and security > policies to fail silently. **1. Targeting a specific tool on a server** Combine `mcpName` and `toolName` to target a single operation. When using `mcpName`, the `toolName` field should strictly be the simple name of the tool (e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`). ```toml # Allows the `search` tool on the `my-jira-server` MCP [[rule]] mcpName = "my-jira-server" toolName = "search" decision = "allow" priority = 200 ``` **2. Targeting all tools on a specific server** Specify only the `mcpName` to apply a rule to every tool provided by that server. **Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] mcpName = "untrusted-server" decision = "deny" priority = 500 deny_message = "This server is not trusted by the admin." ``` **3. Targeting all MCP servers** Use `mcpName = "*"` to create a rule that applies to **all** tools from **any** registered MCP server. This is useful for setting category-wide defaults. ```toml # Ask user for any tool call from any MCP server [[rule]] mcpName = "*" decision = "ask_user" priority = 10 ``` **4. Targeting a tool name across all servers** Use `mcpName = "*"` with a specific `toolName` to target that operation regardless of which server provides it. ```toml # Allow the `search` tool across all connected MCP servers [[rule]] mcpName = "*" toolName = "search" decision = "allow" priority = 50 ``` ## Default policies The Gemini CLI ships with a set of default policies to provide a safe out-of-the-box experience. - **Read-only tools** (like `read_file`, `glob`) are generally **allowed**. - **Agent delegation** defaults to **`ask_user`** to ensure remote agents can prompt for confirmation, but local sub-agent actions are executed silently and checked individually. - **Write tools** (like `write_file`, `run_shell_command`) default to **`ask_user`**. - In **`yolo`** mode, a high-priority rule allows all tools. - In **`autoEdit`** mode, rules allow certain write operations to happen without prompting. ================================================ FILE: docs/reference/tools.md ================================================ # Tools reference Gemini CLI uses tools to interact with your local environment, access information, and perform actions on your behalf. These tools extend the model's capabilities beyond text generation, letting it read files, execute commands, and search the web. ## How to use Gemini CLI's tools Tools are generally invoked automatically by Gemini CLI when it needs to perform an action. However, you can also trigger specific tools manually using shorthand syntax. ### Automatic execution and security When the model wants to use a tool, Gemini CLI evaluates the request against its security policies. - **User confirmation:** You must manually approve tools that modify files or execute shell commands (mutators). The CLI shows you a diff or the exact command before you confirm. - **Sandboxing:** You can run tool executions in secure, containerized environments to isolate changes from your host system. For more details, see the [Sandboxing](../cli/sandbox.md) guide. - **Trusted folders:** You can configure which directories allow the model to use system tools. For more details, see the [Trusted folders](../cli/trusted-folders.md) guide. Review confirmation prompts carefully before allowing a tool to execute. ### How to use manually-triggered tools You can directly trigger key tools using special syntax in your prompt: - **[File access](../tools/file-system.md#read_many_files) (`@`):** Use the `@` symbol followed by a file or directory path to include its content in your prompt. This triggers the `read_many_files` tool. - **[Shell commands](../tools/shell.md) (`!`):** Use the `!` symbol followed by a system command to execute it directly. This triggers the `run_shell_command` tool. ## How to manage tools Using built-in commands, you can inspect available tools and configure how they behave. ### Tool discovery Use the `/tools` command to see what tools are currently active in your session. - **`/tools`**: Lists all registered tools with their display names. - **`/tools desc`**: Lists all tools with their full descriptions. This is especially useful for verifying that [MCP servers](../tools/mcp-server.md) or custom tools are loaded correctly. ### Tool configuration You can enable, disable, or configure specific tools in your settings. For example, you can set a specific pager for shell commands or configure the browser used for web searches. See the [Settings](../cli/settings.md) guide for details. ## Available tools The following table lists all available tools, categorized by their primary function. | Category | Tool | Kind | Description | | :---------- | :----------------------------------------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Execution | [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation.

**Parameters:** `command`, `description`, `dir_path`, `is_background` | | File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.

**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` | | File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.

**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` | | File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.

**Parameters:** `dir_path`, `ignore`, `file_filtering_options` | | File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.

**Parameters:** `file_path`, `start_line`, `end_line` | | File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.

**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` | | File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.

**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` | | File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.

**Parameters:** `file_path`, `content` | | Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.

**Parameters:** `questions` | | Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.

**Parameters:** `todos` | | Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.

**Parameters:** `name` | | Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.

**Parameters:** `path` | | Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.

**Parameters:** `fact` | | Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.

**Parameters:** `reason` | | Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.

**Parameters:** `plan` | | System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.

**Parameters:** `result` | | Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.

**Parameters:** `query` | | Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.

**Parameters:** `prompt` | ## Under the hood For developers, the tool system is designed to be extensible and robust. The `ToolRegistry` class manages all available tools. You can extend Gemini CLI with custom tools by configuring `tools.discoveryCommand` in your settings or by connecting to MCP servers. > [!NOTE] > For a deep dive into the internal Tool API and how to implement your > own tools in the codebase, see the `packages/core/src/tools/` directory in > GitHub. ## Next steps - Learn how to [Set up an MCP server](../tools/mcp-server.md). - Explore [Agent Skills](../cli/skills.md) for specialized expertise. - See the [Command reference](./commands.md) for slash commands. ================================================ FILE: docs/release-confidence.md ================================================ # Release confidence strategy This document outlines the strategy for gaining confidence in every release of the Gemini CLI. It serves as a checklist and quality gate for release manager to ensure we are shipping a high-quality product. ## The goal To answer the question, "Is this release _truly_ ready for our users?" with a high degree of confidence, based on a holistic evaluation of automated signals, manual verification, and data. ## Level 1: Automated gates (must pass) These are the baseline requirements. If any of these fail, the release is a no-go. ### 1. CI/CD health All workflows in `.github/workflows/ci.yml` must pass on the `main` branch (for nightly) or the release branch (for preview/stable). - **Platforms:** Tests must pass on **Linux and macOS**. > [!NOTE] > Windows tests currently run with `continue-on-error: true`. While a > failure here doesn't block the release technically, it should be > investigated. - **Checks:** - **Linting:** No linting errors (ESLint, Prettier, etc.). - **Typechecking:** No TypeScript errors. - **Unit Tests:** All unit tests in `packages/core` and `packages/cli` must pass. - **Build:** The project must build and bundle successfully. ### 2. End-to-end (E2E) tests All workflows in `.github/workflows/chained_e2e.yml` must pass. - **Platforms:** **Linux, macOS and Windows**. - **Sandboxing:** Tests must pass with both `sandbox:none` and `sandbox:docker` on Linux. ### 3. Post-deployment smoke tests After a release is published to npm, the `smoke-test.yml` workflow runs. This must pass to confirm the package is installable and the binary is executable. - **Command:** `npx -y @google/gemini-cli@ --version` must return the correct version without error. - **Platform:** Currently runs on `ubuntu-latest`. ## Level 2: Manual verification and dogfooding Automated tests cannot catch everything, especially UX issues. ### 1. Dogfooding via `preview` tag The weekly release cadence promotes code from `main` -> `nightly` -> `preview` -> `stable`. - **Requirement:** The `preview` release must be used by maintainers for at least **one week** before being promoted to `stable`. - **Action:** Maintainers should install the preview version locally: ```bash npm install -g @google/gemini-cli@preview ``` - **Goal:** To catch regressions and UX issues in day-to-day usage before they reach the broad user base. ### 2. Critical user journey (CUJ) checklist Before promoting a `preview` release to `stable`, a release manager must manually run through this checklist. - **Setup:** - [ ] Uninstall any existing global version: `npm uninstall -g @google/gemini-cli` - [ ] Clear npx cache (optional but recommended): `npm cache clean --force` - [ ] Install the preview version: `npm install -g @google/gemini-cli@preview` - [ ] Verify version: `gemini --version` - **Authentication:** - [ ] In interactive mode run `/auth` and verify all sign in flows work: - [ ] Sign in with Google - [ ] API Key - [ ] Vertex AI - **Basic prompting:** - [ ] Run `gemini "Tell me a joke"` and verify a sensible response. - [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test context. - **Piped input:** - [ ] Run `echo "Summarize this" | gemini` and verify it processes stdin. - **Context management:** - [ ] In interactive mode, use `@file` to add a local file to context. Ask a question about it. - **Settings:** - [ ] In interactive mode run `/settings` and make modifications - [ ] Validate that setting is changed - **Function calling:** - [ ] In interactive mode, ask gemini to "create a file named hello.md with the content 'hello world'" and verify the file is created correctly. If any of these CUJs fail, the release is a no-go until a patch is applied to the `preview` channel. ### 3. Pre-Launch bug bash (tier 1 and 2 launches) For high-impact releases, an organized bug bash is required to ensure a higher level of quality and to catch issues across a wider range of environments and use cases. **Definition of tiers:** - **Tier 1:** Industry-Moving News 🚀 - **Tier 2:** Important News for Our Users 📣 - **Tier 3:** Relevant, but Not Life-Changing 💡 - **Tier 4:** Bug Fixes ⚒️ **Requirement:** A bug bash must be scheduled at least **72 hours in advance** of any Tier 1 or Tier 2 launch. **Rule of thumb:** A bug bash should be considered for any release that involves: - A blog post - Coordinated social media announcements - Media relations or press outreach - A "Turbo" launch event ## Level 3: Telemetry and data review ### Dashboard health - [ ] Go to `go/gemini-cli-dash`. - [ ] Navigate to the "Tool Call" tab. - [ ] Validate that there are no spikes in errors for the release you would like to promote. ### Model evaluation - [ ] Navigate to `go/gemini-cli-offline-evals-dash`. - [ ] Make sure that the release you want to promote's recurring run is within average eval runs. ## The "go/no-go" decision Before triggering the `Release: Promote` workflow to move `preview` to `stable`: 1. [ ] **Level 1:** CI and E2E workflows are green for the commit corresponding to the current `preview` tag. 2. [ ] **Level 2:** The `preview` version has been out for one week, and the CUJ checklist has been completed successfully by a release manager. No blocking issues have been reported. 3. [ ] **Level 3:** Dashboard Health and Model Evaluation checks have been completed and show no regressions. If all checks pass, proceed with the promotion. ================================================ FILE: docs/releases.md ================================================ # Gemini CLI releases ## `dev` vs `prod` environment Our release flows support both `dev` and `prod` environments. The `dev` environment pushes to a private Github-hosted NPM repository, with the package names beginning with `@google-gemini/**` instead of `@google/**`. The `prod` environment pushes to the public global NPM registry via Wombat Dressing Room, which is Google's system for managing NPM packages in the `@google/**` namespace. The packages are all named `@google/**`. More information can be found about these systems in the [NPM Package Overview](npm.md) ### Package scopes | Package | `prod` (Wombat Dressing Room) | `dev` (Github Private NPM Repo) | | ---------- | ----------------------------- | ----------------------------------------- | | CLI | @google/gemini-cli | @google-gemini/gemini-cli | | Core | @google/gemini-cli-core | @google-gemini/gemini-cli-core A2A Server | | A2A Server | @google/gemini-cli-a2a-server | @google-gemini/gemini-cli-a2a-server | ## Release cadence and tags We will follow https://semver.org/ as closely as possible but will call out when or if we have to deviate from it. Our weekly releases will be minor version increments and any bug or hotfixes between releases will go out as patch versions on the most recent release. Each Tuesday ~20:00 UTC new Stable and Preview releases will be cut. The promotion flow is: - Code is committed to main and pushed each night to nightly - After no more than 1 week on main, code is promoted to the `preview` channel - After 1 week the most recent `preview` channel is promoted to `stable` channel - Patch fixes will be produced against both `preview` and `stable` as needed, with the final 'patch' version number incrementing each time. ### Preview These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag. ```bash npm install -g @google/gemini-cli@preview ``` ### Stable This will be the full promotion of last week's release + any bug fixes and validations. Use `latest` tag. ```bash npm install -g @google/gemini-cli@latest ``` ### Nightly - New releases will be published each day at UTC 00:00. This will be all changes from the main branch as represented at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag. ```bash npm install -g @google/gemini-cli@nightly ``` ## Weekly release promotion Each Tuesday, the on-call engineer will trigger the "Promote Release" workflow. This single action automates the entire weekly release process: 1. **Promotes preview to stable:** The workflow identifies the latest `preview` release and promotes it to `stable`. This becomes the new `latest` version on npm. 2. **Promotes nightly to preview:** The latest `nightly` release is then promoted to become the new `preview` version. 3. **Prepares for next nightly:** A pull request is automatically created and merged to bump the version in `main` in preparation for the next nightly release. This process ensures a consistent and reliable release cadence with minimal manual intervention. ### Source of truth for versioning To ensure the highest reliability, the release promotion process uses the **NPM registry as the single source of truth** for determining the current version of each release channel (`stable`, `preview`, and `nightly`). 1. **Fetch from NPM:** The workflow begins by querying NPM's `dist-tags` (`latest`, `preview`, `nightly`) to get the exact version strings for the packages currently available to users. 2. **Cross-check for integrity:** For each version retrieved from NPM, the workflow performs a critical integrity check: - It verifies that a corresponding **git tag** exists in the repository. - It verifies that a corresponding **GitHub release** has been created. 3. **Halt on discrepancy:** If either the git tag or the GitHub Release is missing for a version listed on NPM, the workflow will immediately fail. This strict check prevents promotions from a broken or incomplete previous release and alerts the on-call engineer to a release state inconsistency that must be manually resolved. 4. **Calculate next version:** Only after these checks pass does the workflow proceed to calculate the next semantic version based on the trusted version numbers retrieved from NPM. This NPM-first approach, backed by integrity checks, makes the release process highly robust and prevents the kinds of versioning discrepancies that can arise from relying solely on git history or API outputs. ## Manual releases For situations requiring a release outside of the regular nightly and weekly promotion schedule, and NOT already covered by patching process, you can use the `Release: Manual` workflow. This workflow provides a direct way to publish a specific version from any branch, tag, or commit SHA. ### How to create a manual release 1. Navigate to the **Actions** tab of the repository. 2. Select the **Release: Manual** workflow from the list. 3. Click the **Run workflow** dropdown button. 4. Fill in the required inputs: - **Version**: The exact version to release (e.g., `v0.6.1`). This must be a valid semantic version with a `v` prefix. - **Ref**: The branch, tag, or full commit SHA to release from. - **NPM Channel**: The npm channel to publish to. The options are `preview`, `nightly`, `latest` (for stable releases), and `dev`. The default is `dev`. - **Dry Run**: Leave as `true` to run all steps without publishing, or set to `false` to perform a live release. - **Force Skip Tests**: Set to `true` to skip the test suite. This is not recommended for production releases. - **Skip GitHub Release**: Set to `true` to skip creating a GitHub release and create an npm release only. - **Environment**: Select the appropriate environment. The `dev` environment is intended for testing. The `prod` environment is intended for production releases. `prod` is the default and will require authorization from a release administrator. 5. Click **Run workflow**. The workflow will then proceed to test (if not skipped), build, and publish the release. If the workflow fails during a non-dry run, it will automatically create a GitHub issue with the failure details. ## Rollback/rollforward In the event that a release has a critical regression, you can quickly roll back to a previous stable version or roll forward to a new patch by changing the npm `dist-tag`. The `Release: Change Tags` workflow provides a safe and controlled way to do this. This is the preferred method for both rollbacks and rollforwards, as it does not require a full release cycle. ### How to change a release tag 1. Navigate to the **Actions** tab of the repository. 2. Select the **Release: Change Tags** workflow from the list. 3. Click the **Run workflow** dropdown button. 4. Fill in the required inputs: - **Version**: The existing package version that you want to point the tag to (e.g., `0.5.0-preview-2`). This version **must** already be published to the npm registry. - **Channel**: The npm `dist-tag` to apply (e.g., `preview`, `stable`). - **Dry Run**: Leave as `true` to log the action without making changes, or set to `false` to perform the live tag change. - **Environment**: Select the appropriate environment. The `dev` environment is intended for testing. The `prod` environment is intended for production releases. `prod` is the default and will require authorization from a release administrator. 5. Click **Run workflow**. The workflow will then run `npm dist-tag add` for the appropriate `gemini-cli`, `gemini-cli-core` and `gemini-cli-a2a-server` packages, pointing the specified channel to the specified version. ## Patching If a critical bug that is already fixed on `main` needs to be patched on a `stable` or `preview` release, the process is now highly automated. ### How to patch #### 1. Create the patch pull request There are two ways to create a patch pull request: **Option A: From a GitHub comment (recommended)** After a pull request containing the fix has been merged, a maintainer can add a comment on that same PR with the following format: `/patch [channel]` - **channel** (optional): - _no channel_ - patches both stable and preview channels (default, recommended for most fixes) - `both` - patches both stable and preview channels (same as default) - `stable` - patches only the stable channel - `preview` - patches only the preview channel Examples: - `/patch` (patches both stable and preview - default) - `/patch both` (patches both stable and preview - explicit) - `/patch stable` (patches only stable) - `/patch preview` (patches only preview) The `Release: Patch from Comment` workflow will automatically find the merge commit SHA and trigger the `Release: Patch (1) Create PR` workflow. If the PR is not yet merged, it will post a comment indicating the failure. **Option B: Manually triggering the workflow** Navigate to the **Actions** tab and run the **Release: Patch (1) Create PR** workflow. - **Commit**: The full SHA of the commit on `main` that you want to cherry-pick. - **Channel**: The channel you want to patch (`stable` or `preview`). This workflow will automatically: 1. Find the latest release tag for the channel. 2. Create a release branch from that tag if one doesn't exist (e.g., `release/v0.5.1-pr-12345`). 3. Create a new hotfix branch from the release branch. 4. Cherry-pick your specified commit into the hotfix branch. 5. Create a pull request from the hotfix branch back to the release branch. #### 2. Review and merge Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. > [!WARNING] > The `release/*` branches are protected by branch protection > rules. A pull request to one of these branches requires at least one review from > a code owner before it can be merged. This ensures that no unauthorized code is > released. #### 2.5. Adding multiple commits to a hotfix (advanced) If you need to include multiple fixes in a single patch release, you can add additional commits to the hotfix branch after the initial patch PR has been created: 1. **Start with the primary fix**: Use `/patch` (or `/patch both`) on the most important PR to create the initial hotfix branch and PR. 2. **Checkout the hotfix branch locally**: ```bash git fetch origin git checkout hotfix/v0.5.1/stable/cherry-pick-abc1234 # Use the actual branch name from the PR ``` 3. **Cherry-pick additional commits**: ```bash git cherry-pick git cherry-pick # Add as many commits as needed ``` 4. **Push the updated branch**: ```bash git push origin hotfix/v0.5.1/stable/cherry-pick-abc1234 ``` 5. **Test and review**: The existing patch PR will automatically update with your additional commits. Test thoroughly since you're now releasing multiple changes together. 6. **Update the PR description**: Consider updating the PR title and description to reflect that it includes multiple fixes. This approach allows you to group related fixes into a single patch release while maintaining full control over what gets included and how conflicts are resolved. #### 3. Automatic release Upon merging the pull request, the `Release: Patch (2) Trigger` workflow is automatically triggered. It will then start the `Release: Patch (3) Release` workflow, which will: 1. Build and test the patched code. 2. Publish the new patch version to npm. 3. Create a new GitHub release with the patch notes. This fully automated process ensures that patches are created and released consistently and reliably. #### Troubleshooting: Older branch workflows **Issue**: If the patch trigger workflow fails with errors like "Resource not accessible by integration" or references to non-existent workflow files (e.g., `patch-release.yml`), this indicates the hotfix branch contains an outdated version of the workflow files. **Root cause**: When a PR is merged, GitHub Actions runs the workflow definition from the **source branch** (the hotfix branch), not from the target branch (the release branch). If the hotfix branch was created from an older release branch that predates workflow improvements, it will use the old workflow logic. **Solutions**: **Option 1: Manual trigger (quick fix)** Manually trigger the updated workflow from the branch with the latest workflow code: ```bash # For a preview channel patch with tests skipped gh workflow run release-patch-2-trigger.yml --ref \ --field ref="hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234" \ --field workflow_ref= \ --field dry_run=false \ --field force_skip_tests=true # For a stable channel patch gh workflow run release-patch-2-trigger.yml --ref \ --field ref="hotfix/v0.5.1/stable/cherry-pick-abc1234" \ --field workflow_ref= \ --field dry_run=false \ --field force_skip_tests=false # Example using main branch (most common case) gh workflow run release-patch-2-trigger.yml --ref main \ --field ref="hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234" \ --field workflow_ref=main \ --field dry_run=false \ --field force_skip_tests=true ``` **Note**: Replace `` with the branch containing the latest workflow improvements (usually `main`, but could be a feature branch if testing updates). **Option 2: Update the hotfix branch** Merge the latest main branch into your hotfix branch to get the updated workflows: ```bash git checkout hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234 git merge main git push ``` Then close and reopen the PR to retrigger the workflow with the updated version. **Option 3: Direct release trigger** Skip the trigger workflow entirely and directly run the release workflow: ```bash # Replace channel and release_ref with appropriate values gh workflow run release-patch-3-release.yml --ref main \ --field type="preview" \ --field dry_run=false \ --field force_skip_tests=true \ --field release_ref="release/v0.6.0-preview.2" ``` ### Docker We also run a Google cloud build called [release-docker.yml](../.gcp/release-docker.yml). Which publishes the sandbox docker to match your release. This will also be moved to GH and combined with the main release file once service account permissions are sorted out. ## Release validation After pushing a new release smoke testing should be performed to ensure that the packages are working as expected. This can be done by installing the packages locally and running a set of tests to ensure that they are functioning correctly. - `npx -y @google/gemini-cli@latest --version` to validate the push worked as expected if you were not doing a rc or dev tag - `npx -y @google/gemini-cli@ --version` to validate the tag pushed appropriately - _This is destructive locally_ `npm uninstall @google/gemini-cli && npm uninstall -g @google/gemini-cli && npm cache clean --force && npm install @google/gemini-cli@` - Smoke testing a basic run through of exercising a few llm commands and tools is recommended to ensure that the packages are working as expected. We'll codify this more in the future. ## Local testing and validation: Changes to the packaging and publishing process If you need to test the release process without actually publishing to NPM or creating a public GitHub release, you can trigger the workflow manually from the GitHub UI. 1. Go to the [Actions tab](https://github.com/google-gemini/gemini-cli/actions/workflows/release-manual.yml) of the repository. 2. Click on the "Run workflow" dropdown. 3. Leave the `dry_run` option checked (`true`). 4. Click the "Run workflow" button. This will run the entire release process but will skip the `npm publish` and `gh release create` steps. You can inspect the workflow logs to ensure everything is working as expected. It is crucial to test any changes to the packaging and publishing process locally before committing them. This ensures that the packages will be published correctly and that they will work as expected when installed by a user. To validate your changes, you can perform a dry run of the publishing process. This will simulate the publishing process without actually publishing the packages to the npm registry. ```bash npm_package_version=9.9.9 SANDBOX_IMAGE_REGISTRY="registry" SANDBOX_IMAGE_NAME="thename" npm run publish:npm --dry-run ``` This command will do the following: 1. Build all the packages. 2. Run all the prepublish scripts. 3. Create the package tarballs that would be published to npm. 4. Print a summary of the packages that would be published. You can then inspect the generated tarballs to ensure that they contain the correct files and that the `package.json` files have been updated correctly. The tarballs will be created in the root of each package's directory (e.g., `packages/cli/google-gemini-cli-0.1.6.tgz`). By performing a dry run, you can be confident that your changes to the packaging process are correct and that the packages will be published successfully. ## Release deep dive The release process creates two distinct types of artifacts for different distribution channels: standard packages for the NPM registry and a single, self-contained executable for GitHub Releases. Here are the key stages: **Stage 1: Pre-release sanity checks and versioning** - **What happens:** Before any files are moved, the process ensures the project is in a good state. This involves running tests, linting, and type-checking (`npm run preflight`). The version number in the root `package.json` and `packages/cli/package.json` is updated to the new release version. **Stage 2: Building the source code for NPM** - **What happens:** The TypeScript source code in `packages/core/src` and `packages/cli/src` is compiled into standard JavaScript. - **File movement:** - `packages/core/src/**/*.ts` -> compiled to -> `packages/core/dist/` - `packages/cli/src/**/*.ts` -> compiled to -> `packages/cli/dist/` - **Why:** The TypeScript code written during development needs to be converted into plain JavaScript that can be run by Node.js. The `core` package is built first as the `cli` package depends on it. **Stage 3: Publishing standard packages to NPM** - **What happens:** The `npm publish` command is run for the `@google/gemini-cli-core` and `@google/gemini-cli` packages. - **Why:** This publishes them as standard Node.js packages. Users installing via `npm install -g @google/gemini-cli` will download these packages, and `npm` will handle installing the `@google/gemini-cli-core` dependency automatically. The code in these packages is not bundled into a single file. **Stage 4: Assembling and creating the GitHub release asset** This stage happens _after_ the NPM publish and creates the single-file executable that enables `npx` usage directly from the GitHub repository. 1. **The JavaScript bundle is created:** - **What happens:** The built JavaScript from both `packages/core/dist` and `packages/cli/dist`, along with all third-party JavaScript dependencies, are bundled by `esbuild` into a single, executable JavaScript file (e.g., `gemini.js`). The `node-pty` library is excluded from this bundle as it contains native binaries. - **Why:** This creates a single, optimized file that contains all the necessary application code. It simplifies execution for users who want to run the CLI without a full `npm install`, as all dependencies (including the `core` package) are included directly. 2. **The `bundle` directory is assembled:** - **What happens:** A temporary `bundle` folder is created at the project root. The single `gemini.js` executable is placed inside it, along with other essential files. - **File movement:** - `gemini.js` (from esbuild) -> `bundle/gemini.js` - `README.md` -> `bundle/README.md` - `LICENSE` -> `bundle/LICENSE` - `packages/cli/src/utils/*.sb` (sandbox profiles) -> `bundle/` - **Why:** This creates a clean, self-contained directory with everything needed to run the CLI and understand its license and usage. 3. **The GitHub release is created:** - **What happens:** The contents of the `bundle` directory, including the `gemini.js` executable, are attached as assets to a new GitHub Release. - **Why:** This makes the single-file version of the CLI available for direct download and enables the `npx https://github.com/google-gemini/gemini-cli` command, which downloads and runs this specific bundled asset. **Summary of artifacts** - **NPM:** Publishes standard, un-bundled Node.js packages. The primary artifact is the code in `packages/cli/dist`, which depends on `@google/gemini-cli-core`. - **GitHub release:** Publishes a single, bundled `gemini.js` file that contains all dependencies, for easy execution via `npx`. This dual-artifact process ensures that both traditional `npm` users and those who prefer the convenience of `npx` have an optimized experience. ## Notifications Failing release workflows will automatically create an issue with the label `release-failure`. A notification will be posted to the maintainer's chat channel when issues with this type are created. ### Modifying chat notifications Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space. > [!WARNING] > The following instructions describe a fragile workaround that depends on the > internal structure of the chat application's UI. It is likely to break with > future updates. The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the repo, you must use your browser's developer tools to manually modify the UI: 1. Open your browser's developer tools (e.g., Chrome DevTools). 2. In the `/github-settings` dialog, inspect the list of labels. 3. Locate one of the `
  • ` elements representing a label. 4. In the HTML, modify the `data-option-value` attribute of that `
  • ` element to the desired label name (e.g., `release-failure`). 5. Click on your modified label in the UI to select it, then save your settings. ================================================ FILE: docs/resources/faq.md ================================================ # Frequently asked questions (FAQ) This page provides answers to common questions and solutions to frequent problems encountered while using Gemini CLI. ## General issues This section addresses common questions about Gemini CLI usage, security, and troubleshooting general errors. ### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI? Using third-party software, tools, or services to harvest or piggyback on Gemini CLI's OAuth authentication to access our backend services is a direct violation of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our intended authentication and security structures, and such actions may be grounds for immediate suspension or termination of your account. If you would like to use a third-party coding agent with Gemini, the supported and secure method is to use a Vertex AI or Google AI Studio API key. ### Why am I getting an `API error: 429 - Resource exhausted`? This error indicates that you have exceeded your API request limit. The Gemini API has rate limits to prevent abuse and ensure fair usage. To resolve this, you can: - **Check your usage:** Review your API usage in the Google AI Studio or your Google Cloud project dashboard. - **Optimize your prompts:** If you are making many requests in a short period, try to batch your prompts or introduce delays between requests. - **Request a quota increase:** If you consistently need a higher limit, you can request a quota increase from Google. ### Why am I getting an `ERR_REQUIRE_ESM` error when running `npm run start`? This error typically occurs in Node.js projects when there is a mismatch between CommonJS and ES Modules. This is often due to a misconfiguration in your `package.json` or `tsconfig.json`. Ensure that: 1. Your `package.json` has `"type": "module"`. 2. Your `tsconfig.json` has `"module": "NodeNext"` or a compatible setting in the `compilerOptions`. If the problem persists, try deleting your `node_modules` directory and `package-lock.json` file, and then run `npm install` again. ### Why don't I see cached token counts in my stats output? Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI. ## Installation and updates ### How do I check which version of Gemini CLI I'm currently running? You can check your current Gemini CLI version using one of these methods: - Run `gemini --version` or `gemini -v` from your terminal - Check the globally installed version using your package manager: - npm: `npm list -g @google/gemini-cli` - pnpm: `pnpm list -g @google/gemini-cli` - yarn: `yarn global list @google/gemini-cli` - bun: `bun pm ls -g @google/gemini-cli` - homebrew: `brew list --versions gemini-cli` - Inside an active Gemini CLI session, use the `/about` command ### How do I update Gemini CLI to the latest version? If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. ## Platform-specific issues ### Why does the CLI crash on Windows when I run a command like `chmod +x`? Commands like `chmod` are specific to Unix-like operating systems (Linux, macOS). They are not available on Windows by default. To resolve this, you can: - **Use Windows-equivalent commands:** Instead of `chmod`, you can use `icacls` to modify file permissions on Windows. - **Use a compatibility layer:** Tools like Git Bash or Windows Subsystem for Linux (WSL) provide a Unix-like environment on Windows where these commands will work. ## Configuration ### How do I configure my `GOOGLE_CLOUD_PROJECT`? You can configure your Google Cloud Project ID using an environment variable. Set the `GOOGLE_CLOUD_PROJECT` environment variable in your shell: **macOS/Linux** ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" ``` **Windows (PowerShell)** ```powershell $env:GOOGLE_CLOUD_PROJECT="your-project-id" ``` To make this setting permanent, add this line to your shell's startup file (e.g., `~/.bashrc`, `~/.zshrc`). ### What is the best way to store my API keys securely? Exposing API keys in scripts or checking them into source control is a security risk. To store your API keys securely, you can: - **Use a `.env` file:** Create a `.env` file in your project's `.gemini` directory (`.gemini/.env`) and store your keys there. Gemini CLI will automatically load these variables. - **Use your system's keyring:** For the most secure storage, use your operating system's secret management tool (like macOS Keychain, Windows Credential Manager, or a secret manager on Linux). You can then have your scripts or environment load the key from the secure storage at runtime. ### Where are the Gemini CLI configuration and settings files stored? The Gemini CLI configuration is stored in two `settings.json` files: 1. In your home directory: `~/.gemini/settings.json`. 2. In your project's root directory: `./.gemini/settings.json`. Refer to [Gemini CLI Configuration](../reference/configuration.md) for more details. ## Google AI Pro/Ultra and subscription FAQs ### Where can I learn more about my Google AI Pro or Google AI Ultra subscription? To learn more about your Google AI Pro or Google AI Ultra subscription, visit **Manage subscription** in your [subscription settings](https://one.google.com). ### How do I know if I have higher limits for Google AI Pro or Ultra? If you're subscribed to Google AI Pro or Ultra, you automatically have higher limits to Gemini Code Assist and Gemini CLI. These are shared across Gemini CLI and agent mode in the IDE. You can confirm you have higher limits by checking if you are still subscribed to Google AI Pro or Ultra in your [subscription settings](https://one.google.com). ### What is the privacy policy for using Gemini Code Assist or Gemini CLI if I've subscribed to Google AI Pro or Ultra? To learn more about your privacy policy and terms of service governed by your subscription, visit [Gemini Code Assist: Terms of Service and Privacy Policies](https://developers.google.com/gemini-code-assist/resources/privacy-notices). ### I've upgraded to Google AI Pro or Ultra but it still says I am hitting quota limits. Is this a bug? The higher limits in your Google AI Pro or Ultra subscription are for Gemini 2.5 across both Gemini 2.5 Pro and Flash. They are shared quota across Gemini CLI and agent mode in Gemini Code Assist IDE extensions. You can learn more about quota limits for Gemini CLI, Gemini Code Assist and agent mode in Gemini Code Assist at [Quotas and limits](https://developers.google.com/gemini-code-assist/resources/quotas). ### If I upgrade to higher limits for Gemini CLI and Gemini Code Assist by purchasing a Google AI Pro or Ultra subscription, will Gemini start using my data to improve its machine learning models? Google does not use your data to improve Google's machine learning models if you purchase a paid plan. Note: If you decide to remain on the free version of Gemini Code Assist, Gemini Code Assist for individuals, you can also opt out of using your data to improve Google's machine learning models. See the [Gemini Code Assist for individuals privacy notice](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) for more information. ## Not seeing your question? Search the [Gemini CLI Q&A discussions on GitHub](https://github.com/google-gemini/gemini-cli/discussions/categories/q-a) or [start a new discussion on GitHub](https://github.com/google-gemini/gemini-cli/discussions/new?category=q-a) ================================================ FILE: docs/resources/quota-and-pricing.md ================================================ # Gemini CLI: Quotas and pricing Gemini CLI offers a generous free tier that covers many individual developers' use cases. For enterprise or professional usage, or if you need increased quota, several options are available depending on your authentication account type. For a high-level comparison of available subscriptions and to select the right quota for your needs, see the [Plans page](https://geminicli.com/plans/). ## Overview This article outlines the specific quotas and pricing applicable to Gemini CLI when using different authentication methods. Generally, there are three categories to choose from: - Free Usage: Ideal for experimentation and light use. - Paid Tier (fixed price): For individual developers or enterprises who need more generous daily quotas and predictable costs. - Pay-As-You-Go: The most flexible option for professional use, long-running tasks, or when you need full control over your usage. ## Free usage Access to Gemini CLI begins with a generous free tier, perfect for experimentation and light use. Your free usage is governed by the following limits, which depend on your authorization type. ### Log in with Google (Gemini Code Assist for individuals) For users who authenticate by using their Google account to access Gemini Code Assist for individuals. This includes: - 1000 model requests / user / day - 60 model requests / user / minute - Model requests will be made across the Gemini model family as determined by Gemini CLI. Learn more at [Gemini Code Assist for Individuals Limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). ### Log in with Gemini API Key (unpaid) If you are using a Gemini API key, you can also benefit from a free tier. This includes: - 250 model requests / user / day - 10 model requests / user / minute - Model requests to Flash model only. Learn more at [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits). ### Log in with Vertex AI (Express Mode) Vertex AI offers an Express Mode without the need to enable billing. This includes: - 90 days before you need to enable billing. - Quotas and models are variable and specific to your account. Learn more at [Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas). ## Paid tier: Higher limits for a fixed cost If you use up your initial number of requests, you can continue to benefit from Gemini CLI by upgrading to one of the following subscriptions: ### Individuals These tiers apply when you sign in with a personal account. To verify whether you're on a personal account, visit [Google One](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0): - If you are on a personal account, you will see your personal dashboard. - If you are not on a personal account, you will see: "You're currently signed in to your Google Workspace Account." **Supported tiers:** _- Tiers not listed above, including Google AI Plus, are not supported._ - [Google AI Pro and AI Ultra](https://gemini.google/subscriptions/). This is recommended for individual developers. Quotas and pricing are based on a fixed price subscription. For predictable costs, you can log in with Google. Learn more at [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas) ### Through your organization These tiers are applicable when you are signing in with a Google Workspace account. - To verify your account type, visit [the Google One page](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0). - You are on a workspace account if you see the message "You're currently signed in to your Google Workspace Account". **Supported tiers:** _- Tiers not listed above, including Workspace AI Standard/Plus and AI Expanded, are not supported._ - [Workspace AI Ultra Access](https://workspace.google.com/products/ai-ultra/). - [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview). Quotas and pricing are based on a fixed price subscription with assigned license seats. For predictable costs, you can sign in with Google. This includes the following request limits: - Gemini Code Assist Standard edition: - 1500 model requests / user / day - 120 model requests / user / minute - Gemini Code Assist Enterprise edition: - 2000 model requests / user / day - 120 model requests / user / minute - Model requests will be made across the Gemini model family as determined by Gemini CLI. [Learn more about Gemini Code Assist license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). ## Pay as you go If you hit your daily request limits or exhaust your Gemini Pro quota even after upgrading, the most flexible solution is to switch to a pay-as-you-go model, where you pay for the specific amount of processing you use. This is the recommended path for uninterrupted access. To do this, log in using a Gemini API key or Vertex AI. ### Vertex AI (regular mode) An enterprise-grade platform for building, deploying, and managing AI models, including Gemini. It offers enhanced security, data governance, and integration with other Google Cloud services. - Quota: Governed by a dynamic shared quota system or pre-purchased provisioned throughput. - Cost: Based on model and token usage. Learn more at [Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota) and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). ### Gemini API key Ideal for developers who want to quickly build applications with the Gemini models. This is the most direct way to use the models. - Quota: Varies by pricing tier. - Cost: Varies by pricing tier and model/token usage. Learn more at [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits), [Gemini API Pricing](https://ai.google.dev/gemini-api/docs/pricing) It’s important to highlight that when using an API key, you pay per token/call. This can be more expensive for many small calls with few tokens, but it's the only way to ensure your workflow isn't interrupted by reaching a limit on your quota. ## Gemini for workspace plans These plans currently apply only to the use of Gemini web-based products provided by Google-based experiences (for example, the Gemini web app or the Flow video editor). These plans do not apply to the API usage which powers the Gemini CLI. Supporting these plans is under active consideration for future support. ## Check usage and limits You can check your current token usage and applicable limits using the `/stats model` command. This command provides a snapshot of your current session's token usage, as well as information about the limits associated with your current quota. For more information on the `/stats` command and its subcommands, see the [Command Reference](../reference/commands.md#stats). A summary of model usage is also presented on exit at the end of a session. ## Tips to avoid high costs When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected costs. - **Be selective with suggestions**: Before accepting a suggestion, especially for a computationally intensive task like refactoring a large codebase, consider if it's the most cost-effective approach. - **Use precise prompts**: You are paying per call, so think about the most efficient way to get your desired result. A well-crafted prompt can often get you the answer you need in a single call, rather than multiple back-and-forth interactions. - **Monitor your usage**: Use the `/stats model` command to track your token usage during a session. This can help you stay aware of your spending in real time. ================================================ FILE: docs/resources/tos-privacy.md ================================================ # Gemini CLI: License, Terms of Service, and Privacy Notices Gemini CLI is an open-source tool that lets you interact with Google's powerful AI services directly from your command-line interface. The Gemini CLI software is licensed under the [Apache 2.0 license](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE). When you use Gemini CLI to access or use Google’s services, the Terms of Service and Privacy Notices applicable to those services apply to such access and use. Directly accessing the services powering Gemini CLI (e.g., the Gemini Code Assist service) using third-party software, tools, or services (for example, using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and policies. Such actions may be grounds for suspension or termination of your account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. > [!NOTE] > See [quotas and pricing](quota-and-pricing.md) for the quota and > pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods Your authentication method refers to the method you use to log into and access Google’s services with Gemini CLI. Supported authentication methods include: - Logging in with your Google account to Gemini Code Assist. - Using an API key with Gemini Developer API. - Using an API key with Vertex AI GenAI API. The Terms of Service and Privacy Notices applicable to the aforementioned Google services are set forth in the table below. If you log in with your Google account and you do not already have a Gemini Code Assist account associated with your Google account, you will be directed to the sign up flow for Gemini Code Assist for individuals. If your Google account is managed by your organization, your administrator may not permit access to Gemini Code Assist for individuals. Please see the [Gemini Code Assist for individuals FAQs](https://developers.google.com/gemini-code-assist/resources/faqs) for further information. | Authentication Method | Service(s) | Terms of Service | Privacy Notice | | :----------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------- | | Google Account | Gemini Code Assist services | [Terms of Service](https://developers.google.com/gemini-code-assist/resources/privacy-notices) | [Privacy Notices](https://developers.google.com/gemini-code-assist/resources/privacy-notices) | | Gemini Developer API Key | Gemini API - Unpaid Services | [Gemini API Terms of Service - Unpaid Services](https://ai.google.dev/gemini-api/terms#unpaid-services) | [Google Privacy Policy](https://policies.google.com/privacy) | | Gemini Developer API Key | Gemini API - Paid Services | [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) | [Google Privacy Policy](https://policies.google.com/privacy) | | Vertex AI GenAI API Key | Vertex AI GenAI API | [Google Cloud Platform Terms of Service](https://cloud.google.com/terms/service-terms/) | [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) | ## 1. If you have signed in with your Google account to Gemini Code Assist For users who use their Google account to access [Gemini Code Assist](https://codeassist.google), these Terms of Service and Privacy Notice documents apply: - Gemini Code Assist for individuals: [Google Terms of Service](https://policies.google.com/terms) and [Gemini Code Assist for individuals Privacy Notice](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals). - Gemini Code Assist with Google AI Pro or Ultra subscription: [Google Terms of Service](https://policies.google.com/terms), [Google One Additional Terms of Service](https://one.google.com/terms-of-service) and [Google Privacy Policy\*](https://policies.google.com/privacy). - Gemini Code Assist Standard and Enterprise editions: [Google Cloud Platform Terms of Service](https://cloud.google.com/terms) and [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice). _\* If your account is also associated with an active subscription to Gemini Code Assist Standard or Enterprise edition, the terms and privacy policy of Gemini Code Assist Standard or Enterprise edition will apply to all your use of Gemini Code Assist._ ## 2. If you have signed in with a Gemini API key to the Gemini Developer API If you are using a Gemini API key for authentication with the [Gemini Developer API](https://ai.google.dev/gemini-api/docs), these Terms of Service and Privacy Notice documents apply: - Terms of Service: Your use of the Gemini CLI is governed by the [Gemini API Terms of Service](https://ai.google.dev/gemini-api/terms). These terms may differ depending on whether you are using an unpaid or paid service: - For unpaid services, refer to the [Gemini API Terms of Service - Unpaid Services](https://ai.google.dev/gemini-api/terms#unpaid-services). - For paid services, refer to the [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services). - Privacy Notice: The collection and use of your data is described in the [Google Privacy Policy](https://policies.google.com/privacy). ## 3. If you have signed in with a Gemini API key to the Vertex AI GenAI API If you are using a Gemini API key for authentication with a [Vertex AI GenAI API](https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest) backend, these Terms of Service and Privacy Notice documents apply: - Terms of Service: Your use of the Gemini CLI is governed by the [Google Cloud Platform Service Terms](https://cloud.google.com/terms/service-terms/). - Privacy Notice: The collection and use of your data is described in the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice). ## Usage statistics opt-out You may opt-out from sending Gemini CLI Usage Statistics to Google by following the instructions available here: [Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics). ================================================ FILE: docs/resources/troubleshooting.md ================================================ # Troubleshooting guide This guide provides solutions to common issues and debugging tips, including topics on: - Authentication or login errors - Frequently asked questions (FAQs) - Debugging tips - Existing GitHub Issues similar to yours or creating new Issues ## Authentication or login errors - **Error: `You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`** - **Cause:** This error might occur if Gemini CLI detects the `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is defined. Setting these variables forces an organization subscription check. This might be an issue if you are using an individual Google account not linked to an organizational subscription. - **Solution:** - **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these variables from your shell configuration files (for example, `.bashrc`, `.zshrc`) and any `.env` files. If this doesn't resolve the issue, try using a different Google account. - **Organizational Users:** Contact your Google Cloud administrator to be added to your organization's Gemini Code Assist subscription. - **Error: `Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`** - **Cause:** Gemini CLI does not currently support your location. For a full list of supported locations, see the following pages: - Gemini Code Assist for individuals: [Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) - **Error: `Failed to sign in. Message: Request contains an invalid argument`** - **Cause:** Users with Google Workspace accounts or Google Cloud accounts associated with their Gmail accounts may not be able to activate the free tier of the Google Code Assist plan. - **Solution:** For Google Cloud accounts, you can work around this by setting `GOOGLE_CLOUD_PROJECT` to your project ID. Alternatively, you can obtain the Gemini API key from [Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a separate free tier. - **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`** - **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js. - **Solution:** First try setting `NODE_USE_SYSTEM_CA`; if that does not resolve the issue, set `NODE_EXTRA_CA_CERTS`. - Set the `NODE_USE_SYSTEM_CA=1` environment variable to tell Node.js to use the operating system's native certificate store (where corporate certificates are typically already installed). - Example: `export NODE_USE_SYSTEM_CA=1` (Windows PowerShell: `$env:NODE_USE_SYSTEM_CA=1`) - Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file. - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` (Windows PowerShell: `$env:NODE_EXTRA_CA_CERTS="C:\path\to\your\corporate-ca.crt"`) ## Common error messages and solutions - **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.** - **Cause:** Another process is already using the port that the MCP server is trying to bind to. - **Solution:** Either stop the other process that is using the port or configure the MCP server to use a different port. - **Error: Command not found (when attempting to run Gemini CLI with `gemini`).** - **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`. - **Solution:** The update depends on how you installed Gemini CLI: - If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. - If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`. - **Error: `MODULE_NOT_FOUND` or import errors.** - **Cause:** Dependencies are not installed correctly, or the project hasn't been built. - **Solution:** 1. Run `npm install` to ensure all dependencies are present. 2. Run `npm run build` to compile the project. 3. Verify that the build completed successfully with `npm run start`. - **Error: "Operation not permitted", "Permission denied", or similar.** - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md) documentation for more information, including how to customize your sandbox configuration. - **Gemini CLI is not running in interactive mode in "CI" environments** - **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode. - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini` - **DEBUG mode not working from project .env file** - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli. - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. - **Solution:** Use a `.gemini/.env` file instead, or configure the `advanced.excludedEnvVars` setting in your `settings.json` to exclude fewer variables. - **Warning: `npm WARN deprecated node-domexception@1.0.0` or `npm WARN deprecated glob` during install/update** - **Issue:** When installing or updating the Gemini CLI globally via `npm install -g @google/gemini-cli` or `npm update -g @google/gemini-cli`, you might see deprecation warnings regarding `node-domexception` or old versions of `glob`. - **Cause:** These warnings occur because some dependencies (or their sub-dependencies, like `google-auth-library`) rely on older package versions. Since Gemini CLI requires Node.js 20 or higher, the platform's native features (like the native `DOMException`) are used, making these warnings purely informational. - **Solution:** These warnings are harmless and can be safely ignored. Your installation or update will complete successfully and function properly without any action required. ## Exit codes The Gemini CLI uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation. | Exit Code | Error Type | Description | | --------- | -------------------------- | --------------------------------------------------------------------------------------------------- | | 41 | `FatalAuthenticationError` | An error occurred during the authentication process. | | 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) | | 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g., Docker, Podman, or Seatbelt). | | 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. | | 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) | ## Debugging tips - **CLI debugging:** - Use the `--debug` flag for more detailed output. In interactive mode, press F12 to view the debug console. - Check the CLI logs, often found in a user-specific configuration or cache directory. - **Core debugging:** - Check the server console output for error messages or stack traces. - Increase log verbosity if configurable. For example, set the `DEBUG_MODE` environment variable to `true` or `1`. - Use Node.js debugging tools (e.g., `node --inspect`) if you need to step through server-side code. - **Tool issues:** - If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs. - For `run_shell_command`, check that the command works directly in your shell first. - For _file system tools_, verify that paths are correct and check the permissions. - **Pre-flight checks:** - Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors. ## Existing GitHub issues similar to yours or creating new issues If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! > [!NOTE] > Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. ================================================ FILE: docs/resources/uninstall.md ================================================ # Uninstalling the CLI Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation. ## Method 1: Using npx npx runs packages from a temporary cache without a permanent installation. To "uninstall" the CLI, you must clear this cache, which will remove gemini-cli and any other packages previously executed with npx. The npx cache is a directory named `_npx` inside your main npm cache folder. You can find your npm cache path by running `npm config get cache`. **For macOS / Linux** ```bash # The path is typically ~/.npm/_npx rm -rf "$(npm config get cache)/_npx" ``` **For Windows (PowerShell)** ```powershell # The path is typically $env:LocalAppData\npm-cache\_npx Remove-Item -Path (Join-Path $env:LocalAppData "npm-cache\_npx") -Recurse -Force ``` ## Method 2: Using npm (global install) If you installed the CLI globally (e.g., `npm install -g @google/gemini-cli`), use the `npm uninstall` command with the `-g` flag to remove it. ```bash npm uninstall -g @google/gemini-cli ``` This command completely removes the package from your system. ## Method 3: Homebrew If you installed the CLI globally using Homebrew (e.g., `brew install gemini-cli`), use the `brew uninstall` command to remove it. ```bash brew uninstall gemini-cli ``` ## Method 4: MacPorts If you installed the CLI globally using MacPorts (e.g., `sudo port install gemini-cli`), use the `port uninstall` command to remove it. ```bash sudo port uninstall gemini-cli ``` ================================================ FILE: docs/sidebar.json ================================================ [ { "label": "docs_tab", "items": [ { "label": "Get started", "items": [ { "label": "Overview", "slug": "docs" }, { "label": "Quickstart", "slug": "docs/get-started" }, { "label": "Installation", "slug": "docs/get-started/installation" }, { "label": "Authentication", "slug": "docs/get-started/authentication" }, { "label": "Examples", "slug": "docs/get-started/examples" }, { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, { "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" } ] }, { "label": "Use Gemini CLI", "items": [ { "label": "File management", "slug": "docs/cli/tutorials/file-management" }, { "label": "Get started with Agent skills", "slug": "docs/cli/tutorials/skills-getting-started" }, { "label": "Manage context and memory", "slug": "docs/cli/tutorials/memory-management" }, { "label": "Execute shell commands", "slug": "docs/cli/tutorials/shell-commands" }, { "label": "Manage sessions and history", "slug": "docs/cli/tutorials/session-management" }, { "label": "Plan tasks with todos", "slug": "docs/cli/tutorials/task-planning" }, { "label": "Use Plan Mode with model steering", "badge": "🔬", "slug": "docs/cli/tutorials/plan-mode-steering" }, { "label": "Web search and fetch", "slug": "docs/cli/tutorials/web-tools" }, { "label": "Set up an MCP server", "slug": "docs/cli/tutorials/mcp-setup" }, { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } ] }, { "label": "Features", "items": [ { "label": "Extensions", "collapsed": true, "items": [ { "label": "Overview", "slug": "docs/extensions" }, { "label": "User guide: Install and manage", "link": "/docs/extensions/#manage-extensions" }, { "label": "Developer guide: Build extensions", "slug": "docs/extensions/writing-extensions" }, { "label": "Developer guide: Best practices", "slug": "docs/extensions/best-practices" }, { "label": "Developer guide: Releasing", "slug": "docs/extensions/releasing" }, { "label": "Developer guide: Reference", "slug": "docs/extensions/reference" } ] }, { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, { "label": "Hooks", "collapsed": true, "items": [ { "label": "Overview", "slug": "docs/hooks" }, { "label": "Reference", "slug": "docs/hooks/reference" } ] }, { "label": "IDE integration", "slug": "docs/ide-integration" }, { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, { "label": "Model steering", "badge": "🔬", "slug": "docs/cli/model-steering" }, { "label": "Notifications", "badge": "🔬", "slug": "docs/cli/notifications" }, { "label": "Plan mode", "slug": "docs/cli/plan-mode" }, { "label": "Subagents", "badge": "🔬", "slug": "docs/core/subagents" }, { "label": "Remote subagents", "badge": "🔬", "slug": "docs/core/remote-agents" }, { "label": "Rewind", "slug": "docs/cli/rewind" }, { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, { "label": "Settings", "slug": "docs/cli/settings" }, { "label": "Telemetry", "slug": "docs/cli/telemetry" }, { "label": "Token caching", "slug": "docs/cli/token-caching" } ] }, { "label": "Configuration", "items": [ { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, { "label": "Enterprise configuration", "slug": "docs/cli/enterprise" }, { "label": "Ignore files (.geminiignore)", "slug": "docs/cli/gemini-ignore" }, { "label": "Model configuration", "slug": "docs/cli/generation-settings" }, { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, { "label": "Settings", "slug": "docs/cli/settings" }, { "label": "System prompt override", "slug": "docs/cli/system-prompt" }, { "label": "Themes", "slug": "docs/cli/themes" }, { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } ] }, { "label": "Development", "items": [ { "label": "Contribution guide", "slug": "docs/contributing" }, { "label": "Integration testing", "slug": "docs/integration-tests" }, { "label": "Issue and PR automation", "slug": "docs/issue-and-pr-automation" }, { "label": "Local development", "slug": "docs/local-development" }, { "label": "NPM package structure", "slug": "docs/npm" } ] } ] }, { "label": "reference_tab", "items": [ { "label": "Reference", "items": [ { "label": "Command reference", "slug": "docs/reference/commands" }, { "label": "Configuration reference", "slug": "docs/reference/configuration" }, { "label": "Keyboard shortcuts", "slug": "docs/reference/keyboard-shortcuts" }, { "label": "Memory import processor", "slug": "docs/reference/memport" }, { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, { "label": "Tools reference", "slug": "docs/reference/tools" } ] } ] }, { "label": "resources_tab", "items": [ { "label": "Resources", "items": [ { "label": "FAQ", "slug": "docs/resources/faq" }, { "label": "Quota and pricing", "slug": "docs/resources/quota-and-pricing" }, { "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" }, { "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" }, { "label": "Uninstall", "slug": "docs/resources/uninstall" } ] } ] }, { "label": "releases_tab", "items": [ { "label": "Releases", "items": [ { "label": "Release notes", "slug": "docs/changelogs/" }, { "label": "Stable release", "slug": "docs/changelogs/latest" }, { "label": "Preview release", "slug": "docs/changelogs/preview" } ] } ] } ] ================================================ FILE: docs/tools/activate-skill.md ================================================ # Activate skill tool (`activate_skill`) The `activate_skill` tool lets Gemini CLI load specialized procedural expertise and resources when they are relevant to your request. ## Description Skills are packages of instructions and tools designed for specific engineering tasks, such as reviewing code or creating pull requests. Gemini CLI uses this tool to "activate" a skill, which provides it with detailed guidelines and specialized tools tailored to that task. ### Arguments `activate_skill` takes one argument: - `name` (enum, required): The name of the skill to activate (for example, `code-reviewer`, `pr-creator`, or `docs-writer`). ## Usage The `activate_skill` tool is used exclusively by the Gemini agent. You cannot invoke this tool manually. When the agent identifies that a task matches a discovered skill, it requests to activate that skill. Once activated, the agent's behavior is guided by the skill's specific instructions until the task is complete. ## Behavior The agent uses this tool to provide professional-grade assistance: - **Specialized logic:** Skills contain expert-level procedures for complex workflows. - **Dynamic capability:** Activating a skill can grant the agent access to new, task-specific tools. - **Contextual awareness:** Skills help the agent focus on the most relevant standards and conventions for a particular task. ## Next steps - Learn how to [Use Agent Skills](../cli/skills.md). - See the [Creating Agent Skills](../cli/creating-skills.md) guide. ================================================ FILE: docs/tools/ask-user.md ================================================ # Ask User Tool The `ask_user` tool lets Gemini CLI ask you one or more questions to gather preferences, clarify requirements, or make decisions. It supports multiple question types including multiple-choice, free-form text, and Yes/No confirmation. ## `ask_user` (Ask User) - **Tool name:** `ask_user` - **Display name:** Ask User - **File:** `ask-user.ts` - **Parameters:** - `questions` (array of objects, required): A list of 1 to 4 questions to ask. Each question object has the following properties: - `question` (string, required): The complete question text. - `header` (string, required): A short label (max 16 chars) displayed as a chip/tag (e.g., "Auth", "Database"). - `type` (string, optional): The type of question. Defaults to `'choice'`. - `'choice'`: Multiple-choice with options (supports multi-select). - `'text'`: Free-form text input. - `'yesno'`: Yes/No confirmation. - `options` (array of objects, optional): Required for `'choice'` type. 2-4 selectable options. - `label` (string, required): Display text (1-5 words). - `description` (string, required): Brief explanation. - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting multiple options. Automatically adds an "All the above" option if there are multiple standard options. - `placeholder` (string, optional): Hint text for input fields. - **Behavior:** - Presents an interactive dialog to the user with the specified questions. - Pauses execution until the user provides answers or dismisses the dialog. - Returns the user's answers to the model. - **Output (`llmContent`):** A JSON string containing the user's answers, indexed by question position (e.g., `{"answers":{"0": "Option A", "1": "Some text"}}`). - **Confirmation:** Yes. The tool inherently involves user interaction. ## Usage Examples ### Multiple Choice Question ```json { "questions": [ { "header": "Database", "question": "Which database would you like to use?", "type": "choice", "options": [ { "label": "PostgreSQL", "description": "Powerful, open source object-relational database system." }, { "label": "SQLite", "description": "C-library that implements a SQL database engine." } ] } ] } ``` ### Text Input Question ```json { "questions": [ { "header": "Project Name", "question": "What is the name of your new project?", "type": "text", "placeholder": "e.g., my-awesome-app" } ] } ``` ### Yes/No Question ```json { "questions": [ { "header": "Deploy", "question": "Do you want to deploy the application now?", "type": "yesno" } ] } ``` ================================================ FILE: docs/tools/file-system.md ================================================ # File system tools reference The Gemini CLI core provides a suite of tools for interacting with the local file system. These tools allow the model to explore and modify your codebase. ## Technical reference All file system tools operate within a `rootDirectory` (the current working directory or workspace root) for security. ### `list_directory` (ReadFolder) Lists the names of files and subdirectories directly within a specified path. - **Tool name:** `list_directory` - **Arguments:** - `dir_path` (string, required): Absolute or relative path to the directory. - `ignore` (array, optional): Glob patterns to exclude. - `file_filtering_options` (object, optional): Configuration for `.gitignore` and `.geminiignore` compliance. ### `read_file` (ReadFile) Reads and returns the content of a specific file. Supports text, images, audio, and PDF. - **Tool name:** `read_file` - **Arguments:** - `file_path` (string, required): Path to the file. - `offset` (number, optional): Start line for text files (0-based). - `limit` (number, optional): Maximum lines to read. ### `write_file` (WriteFile) Writes content to a specified file, overwriting it if it exists or creating it if not. - **Tool name:** `write_file` - **Arguments:** - `file_path` (string, required): Path to the file. - `content` (string, required): Data to write. - **Confirmation:** Requires manual user approval. ### `glob` (FindFiles) Finds files matching specific glob patterns across the workspace. - **Tool name:** `glob` - **Display name:** FindFiles - **File:** `glob.ts` - **Parameters:** - `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`). - `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory. - `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`. - `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`. - **Behavior:** - Searches for files matching the glob pattern within the specified directory. - Returns a list of absolute paths, sorted with the most recently modified files first. - Ignores common nuisance directories like `node_modules` and `.git` by default. - **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...` - **Confirmation:** No. ### `grep_search` (SearchText) `grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers. - **Tool name:** `grep_search` - **Display name:** SearchText - **File:** `grep.ts` - **Parameters:** - `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`). - `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory. - `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores). - **Behavior:** - Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search. - Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number. - **Output (`llmContent`):** A formatted string of matches, e.g.: ``` Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"): --- File: src/utils.ts L15: export function myFunction() { L22: myFunction.call(); --- File: src/index.ts L5: import { myFunction } from './utils'; --- ``` - **Confirmation:** No. ### `replace` (Edit) `replace` replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of `old_string`. If you want to replace multiple occurrences of the exact same string, set `allow_multiple` to `true`. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location. - **Tool name:** `replace` - **Arguments:** - `file_path` (string, required): Path to the file. - `instruction` (string, required): Semantic description of the change. - `old_string` (string, required): Exact literal text to find. - `new_string` (string, required): Exact literal text to replace with. - `allow_multiple` (boolean, optional): If `true`, replaces all occurrences. If `false` (default), only succeeds if exactly one occurrence is found. - **Confirmation:** Requires manual user approval. ## Next steps - Follow the [File management tutorial](../cli/tutorials/file-management.md) for practical examples. - Learn about [Trusted folders](../cli/trusted-folders.md) to manage access permissions. ================================================ FILE: docs/tools/internal-docs.md ================================================ # Internal documentation tool (`get_internal_docs`) The `get_internal_docs` tool lets Gemini CLI access its own technical documentation to provide more accurate answers about its capabilities and usage. ## Description This tool is used when Gemini CLI needs to verify specific details about Gemini CLI's internal features, built-in commands, or configuration options. It provides direct access to the Markdown files in the `docs/` directory. ### Arguments `get_internal_docs` takes one optional argument: - `path` (string, optional): The relative path to a specific documentation file (for example, `reference/commands.md`). If omitted, the tool returns a list of all available documentation paths. ## Usage The `get_internal_docs` tool is used exclusively by Gemini CLI. You cannot invoke this tool manually. When Gemini CLI uses this tool, it retrieves the content of the requested documentation file and processes it to answer your question. This ensures that the information provided by the AI is grounded in the latest project documentation. ## Behavior Gemini CLI uses this tool to ensure technical accuracy: - **Capability discovery:** If Gemini CLI is unsure how a feature works, it can lookup the corresponding documentation. - **Reference lookup:** Gemini CLI can verify slash command sub-commands or specific setting names. - **Self-correction:** Gemini CLI can use the documentation to correct its understanding of Gemini CLI's system logic. ## Next steps - Explore the [Command reference](../reference/commands.md) for a detailed guide to slash commands. - See the [Configuration guide](../reference/configuration.md) for settings reference. ================================================ FILE: docs/tools/mcp-server.md ================================================ # MCP servers with the Gemini CLI This document provides a guide to configuring and using Model Context Protocol (MCP) servers with the Gemini CLI. ## What is an MCP server? An MCP server is an application that exposes tools and resources to the Gemini CLI through the Model Context Protocol, allowing it to interact with external systems and data sources. MCP servers act as a bridge between the Gemini model and your local environment or other services like APIs. An MCP server enables the Gemini CLI to: - **Discover tools:** List available tools, their descriptions, and parameters through standardized schema definitions. - **Execute tools:** Call specific tools with defined arguments and receive structured responses. - **Access resources:** Read data from specific resources that the server exposes (files, API payloads, reports, etc.). With an MCP server, you can extend the Gemini CLI's capabilities to perform actions beyond its built-in features, such as interacting with databases, APIs, custom scripts, or specialized workflows. ## Core integration architecture The Gemini CLI integrates with MCP servers through a sophisticated discovery and execution system built into the core package (`packages/core/src/tools/`): ### Discovery Layer (`mcp-client.ts`) The discovery process is orchestrated by `discoverMcpTools()`, which: 1. **Iterates through configured servers** from your `settings.json` `mcpServers` configuration 2. **Establishes connections** using appropriate transport mechanisms (Stdio, SSE, or Streamable HTTP) 3. **Fetches tool definitions** from each server using the MCP protocol 4. **Sanitizes and validates** tool schemas for compatibility with the Gemini API 5. **Registers tools** in the global tool registry with conflict resolution 6. **Fetches and registers resources** if the server exposes any ### Execution layer (`mcp-tool.ts`) Each discovered MCP tool is wrapped in a `DiscoveredMCPTool` instance that: - **Handles confirmation logic** based on server trust settings and user preferences - **Manages tool execution** by calling the MCP server with proper parameters - **Processes responses** for both the LLM context and user display - **Maintains connection state** and handles timeouts ### Transport mechanisms The Gemini CLI supports three MCP transport types: - **Stdio Transport:** Spawns a subprocess and communicates via stdin/stdout - **SSE Transport:** Connects to Server-Sent Events endpoints - **Streamable HTTP Transport:** Uses HTTP streaming for communication ## Working with MCP resources Some MCP servers expose contextual “resources” in addition to the tools and prompts. Gemini CLI discovers these automatically and gives you the possibility to reference them in the chat. ### Discovery and listing - When discovery runs, the CLI fetches each server’s `resources/list` results. - The `/mcp` command displays a Resources section alongside Tools and Prompts for every connected server. This returns a concise, plain-text list of URIs plus metadata. ### Referencing resources in a conversation You can use the same `@` syntax already known for referencing local files: ``` @server://resource/path ``` Resource URIs appear in the completion menu together with filesystem paths. When you submit the message, the CLI calls `resources/read` and injects the content in the conversation. ## How to set up your MCP server The Gemini CLI uses the `mcpServers` configuration in your `settings.json` file to locate and connect to MCP servers. This configuration supports multiple servers with different transport mechanisms. ### Configure the MCP server in settings.json You can configure MCP servers in your `settings.json` file in two main ways: through the top-level `mcpServers` object for specific server definitions, and through the `mcp` object for global settings that control server discovery and execution. #### Global MCP settings (`mcp`) The `mcp` object in your `settings.json` lets you define global rules for all MCP servers. - **`mcp.serverCommand`** (string): A global command to start an MCP server. - **`mcp.allowed`** (array of strings): A list of MCP server names to allow. If this is set, only servers from this list (matching the keys in the `mcpServers` object) will be connected to. - **`mcp.excluded`** (array of strings): A list of MCP server names to exclude. Servers in this list will not be connected to. **Example:** ```json { "mcp": { "allowed": ["my-trusted-server"], "excluded": ["experimental-server"] } } ``` #### Server-specific configuration (`mcpServers`) The `mcpServers` object is where you define each individual MCP server you want the CLI to connect to. ### Configuration structure Add an `mcpServers` object to your `settings.json` file: ```json { ...file contains other config objects "mcpServers": { "serverName": { "command": "path/to/server", "args": ["--arg1", "value1"], "env": { "API_KEY": "$MY_API_TOKEN" }, "cwd": "./server-directory", "timeout": 30000, "trust": false } } } ``` ### Configuration properties Each server configuration supports the following properties: #### Required (one of the following) - **`command`** (string): Path to the executable for Stdio transport - **`url`** (string): SSE endpoint URL (e.g., `"http://localhost:8080/sse"`) - **`httpUrl`** (string): HTTP streaming endpoint URL #### Optional - **`args`** (string[]): Command-line arguments for Stdio transport - **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl` - **`env`** (object): Environment variables for the server process. Values can reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all platforms), or `%VAR_NAME%` (Windows only). - **`cwd`** (string): Working directory for Stdio transport - **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = 10 minutes) - **`trust`** (boolean): When `true`, bypasses all tool call confirmations for this server (default: `false`) - **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. `excludeTools` takes precedence over `includeTools`. If a tool is in both lists, it will be excluded. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. - **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`. ### Environment variable expansion Gemini CLI automatically expands environment variables in the `env` block of your MCP server configuration. This allows you to securely reference variables defined in your shell or environment without hardcoding sensitive information directly in your `settings.json` file. The expansion utility supports: - **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on all platforms) - **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows) If a variable is not defined in the current environment, it resolves to an empty string. **Example:** ```json "env": { "API_KEY": "$MY_EXTERNAL_TOKEN", "LOG_LEVEL": "$LOG_LEVEL", "TEMP_DIR": "%TEMP%" } ``` ### Security and environment sanitization To protect your credentials, Gemini CLI performs environment sanitization when spawning MCP server processes. #### Automatic redaction By default, the CLI redacts sensitive environment variables from the base environment (inherited from the host process) to prevent unintended exposure to third-party MCP servers. This includes: - Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc. - Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, `*KEY*`, `*AUTH*`, `*CREDENTIAL*`. - Certificates and private key patterns. #### Explicit overrides If an environment variable must be passed to an MCP server, you must explicitly state it in the `env` property of the server configuration in `settings.json`. Explicitly defined variables (including those from extensions) are trusted and are **not** subjected to the automatic redaction process. This follows the security principle that if a variable is explicitly configured by the user for a specific server, it constitutes informed consent to share that specific data with that server. > [!NOTE] > Even when explicitly defined, you should avoid hardcoding secrets. > Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to > securely pull the value from your host environment at runtime. ### OAuth support for remote MCP servers The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or HTTP transports. This enables secure access to MCP servers that require authentication. #### Automatic OAuth discovery For servers that support OAuth discovery, you can omit the OAuth configuration and let the CLI discover it automatically: ```json { "mcpServers": { "discoveredServer": { "url": "https://api.example.com/sse" } } } ``` The CLI will automatically: - Detect when a server requires OAuth authentication (401 responses) - Discover OAuth endpoints from server metadata - Perform dynamic client registration if supported - Handle the OAuth flow and token management #### Authentication flow When connecting to an OAuth-enabled server: 1. **Initial connection attempt** fails with 401 Unauthorized 2. **OAuth discovery** finds authorization and token endpoints 3. **Browser opens** for user authentication (requires local browser access) 4. **Authorization code** is exchanged for access tokens 5. **Tokens are stored** securely for future use 6. **Connection retry** succeeds with valid tokens #### Browser redirect requirements > [!IMPORTANT] > OAuth authentication requires that your local machine can: > > - Open a web browser for authentication > - Receive redirects on `http://localhost:7777/oauth/callback` This feature will not work in: - Headless environments without browser access - Remote SSH sessions without X11 forwarding - Containerized environments without browser support #### Managing OAuth authentication Use the `/mcp auth` command to manage OAuth authentication: ```bash # List servers requiring authentication /mcp auth # Authenticate with a specific server /mcp auth serverName # Re-authenticate if tokens expire /mcp auth serverName ``` #### OAuth configuration properties - **`enabled`** (boolean): Enable OAuth for this server - **`clientId`** (string): OAuth client identifier (optional with dynamic registration) - **`clientSecret`** (string): OAuth client secret (optional for public clients) - **`authorizationUrl`** (string): OAuth authorization endpoint (auto-discovered if omitted) - **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted) - **`scopes`** (string[]): Required OAuth scopes - **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`) - **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs - **`audiences`** (string[]): Audiences the token is valid for #### Token management OAuth tokens are automatically: - **Stored securely** in `~/.gemini/mcp-oauth-tokens.json` - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt - **Cleaned up** when invalid or expired #### Authentication provider type You can specify the authentication provider type using the `authProviderType` property: - **`authProviderType`** (string): Specifies the authentication provider. Can be one of the following: - **`dynamic_discovery`** (default): The CLI will automatically discover the OAuth configuration from the server. - **`google_credentials`**: The CLI will use the Google Application Default Credentials (ADC) to authenticate with the server. When using this provider, you must specify the required scopes. - **`service_account_impersonation`**: The CLI will impersonate a Google Cloud Service Account to authenticate with the server. This is useful for accessing IAP-protected services (this was specifically designed for Cloud Run services). #### Google credentials ```json { "mcpServers": { "googleCloudServer": { "httpUrl": "https://my-gcp-service.run.app/mcp", "authProviderType": "google_credentials", "oauth": { "scopes": ["https://www.googleapis.com/auth/userinfo.email"] } } } } ``` #### Service account impersonation To authenticate with a server using Service Account Impersonation, you must set the `authProviderType` to `service_account_impersonation` and provide the following properties: - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. - **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. The CLI will use your local Application Default Credentials (ADC) to generate an OIDC ID token for the specified service account and audience. This token will then be used to authenticate with the MCP server. #### Setup instructions 1. **[Create](https://cloud.google.com/iap/docs/oauth-client-creation) or use an existing OAuth 2.0 client ID.** To use an existing OAuth 2.0 client ID, follow the steps in [How to share OAuth Clients](https://cloud.google.com/iap/docs/sharing-oauth-clients). 2. **Add the OAuth ID to the allowlist for [programmatic access](https://cloud.google.com/iap/docs/sharing-oauth-clients#programmatic_access) for the application.** Since Cloud Run is not yet a supported resource type in gcloud iap, you must allowlist the Client ID on the project. 3. **Create a service account.** [Documentation](https://cloud.google.com/iam/docs/service-accounts-create#creating), [Cloud Console Link](https://console.cloud.google.com/iam-admin/serviceaccounts) 4. **Add both the service account and users to the IAP Policy** in the "Security" tab of the Cloud Run service itself or via gcloud. 5. **Grant all users and groups** who will access the MCP Server the necessary permissions to [impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation) (i.e., `roles/iam.serviceAccountTokenCreator`). 6. **[Enable](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) the IAM Credentials API** for your project. ### Example configurations #### Python MCP server (stdio) ```json { "mcpServers": { "pythonTools": { "command": "python", "args": ["-m", "my_mcp_server", "--port", "8080"], "cwd": "./mcp-servers/python", "env": { "DATABASE_URL": "$DB_CONNECTION_STRING", "API_KEY": "${EXTERNAL_API_KEY}" }, "timeout": 15000 } } } ``` #### Node.js MCP server (stdio) ```json { "mcpServers": { "nodeServer": { "command": "node", "args": ["dist/server.js", "--verbose"], "cwd": "./mcp-servers/node", "trust": true } } } ``` #### Docker-based MCP server ```json { "mcpServers": { "dockerizedServer": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "API_KEY", "-v", "${PWD}:/workspace", "my-mcp-server:latest" ], "env": { "API_KEY": "$EXTERNAL_SERVICE_TOKEN" } } } } ``` #### HTTP-based MCP server ```json { "mcpServers": { "httpServer": { "httpUrl": "http://localhost:3000/mcp", "timeout": 5000 } } } ``` #### HTTP-based MCP Server with custom headers ```json { "mcpServers": { "httpServerWithAuth": { "httpUrl": "http://localhost:3000/mcp", "headers": { "Authorization": "Bearer your-api-token", "X-Custom-Header": "custom-value", "Content-Type": "application/json" }, "timeout": 5000 } } } ``` #### MCP server with tool filtering ```json { "mcpServers": { "filteredServer": { "command": "python", "args": ["-m", "my_mcp_server"], "includeTools": ["safe_tool", "file_reader", "data_processor"], // "excludeTools": ["dangerous_tool", "file_deleter"], "timeout": 30000 } } } ``` ### SSE MCP server with SA impersonation ```json { "mcpServers": { "myIapProtectedServer": { "url": "https://my-iap-service.run.app/sse", "authProviderType": "service_account_impersonation", "targetAudience": "YOUR_IAP_CLIENT_ID.apps.googleusercontent.com", "targetServiceAccount": "your-sa@your-project.iam.gserviceaccount.com" } } } ``` ## Discovery process deep dive When the Gemini CLI starts, it performs MCP server discovery through the following detailed process: ### 1. Server iteration and connection For each configured server in `mcpServers`: 1. **Status tracking begins:** Server status is set to `CONNECTING` 2. **Transport selection:** Based on configuration properties: - `httpUrl` → `StreamableHTTPClientTransport` - `url` → `SSEClientTransport` - `command` → `StdioClientTransport` 3. **Connection establishment:** The MCP client attempts to connect with the configured timeout 4. **Error handling:** Connection failures are logged and the server status is set to `DISCONNECTED` ### 2. Tool discovery Upon successful connection: 1. **Tool listing:** The client calls the MCP server's tool listing endpoint 2. **Schema validation:** Each tool's function declaration is validated 3. **Tool filtering:** Tools are filtered based on `includeTools` and `excludeTools` configuration 4. **Name sanitization:** Tool names are cleaned to meet Gemini API requirements: - Characters other than letters, numbers, underscore (`_`), hyphen (`-`), dot (`.`), and colon (`:`) are replaced with underscores - Names longer than 63 characters are truncated with middle replacement (`...`) ### 3. Tool naming and namespaces To prevent collisions across multiple servers or conflicting built-in tools, every discovered MCP tool is assigned a strict namespace. 1. **Automatic FQN:** All MCP tools are unconditionally assigned a fully qualified name (FQN) using the format `mcp_{serverName}_{toolName}`. 2. **Registry tracking:** The tool registry maintains metadata mappings between these FQNs and their original server identities. 3. **Overwrites:** If two servers share the exact same alias in your configuration and provide tools with the exact same name, the last registered tool overwrites the previous one. 4. **Policies:** To configure permissions (like auto-approval or denial) for MCP tools, see [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) in the Policy Engine documentation. > [!WARNING] > Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will > misinterpret the server identity, which can cause wildcard rules and security > policies to fail silently. ### 4. Schema processing Tool parameter schemas undergo sanitization for Gemini API compatibility: - **`$schema` properties** are removed - **`additionalProperties`** are stripped - **`anyOf` with `default`** have their default values removed (Vertex AI compatibility) - **Recursive processing** applies to nested schemas ### 5. Connection management After discovery: - **Persistent connections:** Servers that successfully register tools maintain their connections - **Cleanup:** Servers that provide no usable tools have their connections closed - **Status updates:** Final server statuses are set to `CONNECTED` or `DISCONNECTED` ## Tool execution flow When the Gemini model decides to use an MCP tool, the following execution flow occurs: ### 1. Tool invocation The model generates a `FunctionCall` with: - **Tool name:** The registered name (potentially prefixed) - **Arguments:** JSON object matching the tool's parameter schema ### 2. Confirmation process Each `DiscoveredMCPTool` implements sophisticated confirmation logic: #### Trust-based bypass ```typescript if (this.trust) { return false; // No confirmation needed } ``` #### Dynamic allow-listing The system maintains internal allow-lists for: - **Server-level:** `serverName` → All tools from this server are trusted - **Tool-level:** `serverName.toolName` → This specific tool is trusted #### User choice handling When confirmation is required, users can choose: - **Proceed once:** Execute this time only - **Always allow this tool:** Add to tool-level allow-list - **Always allow this server:** Add to server-level allow-list - **Cancel:** Abort execution ### 3. Execution Upon confirmation (or trust bypass): 1. **Parameter preparation:** Arguments are validated against the tool's schema 2. **MCP call:** The underlying `CallableTool` invokes the server with: ```typescript const functionCalls = [ { name: this.serverToolName, // Original server tool name args: params, }, ]; ``` 3. **Response processing:** Results are formatted for both LLM context and user display ### 4. Response handling The execution result contains: - **`llmContent`:** Raw response parts for the language model's context - **`returnDisplay`:** Formatted output for user display (often JSON in markdown code blocks) ## How to interact with your MCP server ### Using the `/mcp` command The `/mcp` command provides comprehensive information about your MCP server setup: ```bash /mcp ``` This displays: - **Server list:** All configured MCP servers - **Connection status:** `CONNECTED`, `CONNECTING`, or `DISCONNECTED` - **Server details:** Configuration summary (excluding sensitive data) - **Available tools:** List of tools from each server with descriptions - **Discovery state:** Overall discovery process status ### Example `/mcp` output ``` MCP Servers Status: 📡 pythonTools (CONNECTED) Command: python -m my_mcp_server --port 8080 Working Directory: ./mcp-servers/python Timeout: 15000ms Tools: calculate_sum, file_analyzer, data_processor 🔌 nodeServer (DISCONNECTED) Command: node dist/server.js --verbose Error: Connection refused 🐳 dockerizedServer (CONNECTED) Command: docker run -i --rm -e API_KEY my-mcp-server:latest Tools: mcp_dockerizedServer_docker_deploy, mcp_dockerizedServer_docker_status Discovery State: COMPLETED ``` ### Tool usage Once discovered, MCP tools are available to the Gemini model like built-in tools. The model will automatically: 1. **Select appropriate tools** based on your requests 2. **Present confirmation dialogs** (unless the server is trusted) 3. **Execute tools** with proper parameters 4. **Display results** in a user-friendly format ## Status monitoring and troubleshooting ### Connection states The MCP integration tracks several states: #### Overriding extension configurations If an MCP server is provided by an extension (for example, the `google-workspace` extension), you can still override its settings in your local `settings.json`. Gemini CLI merges your local configuration with the extension's defaults: - **Tool lists:** Tool lists are merged securely to ensure the most restrictive policy wins: - **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either source blocks a tool, it remains disabled. - **Inclusions (`includeTools`):** Arrays are intersected. If both sources provide an allowlist, only tools present in **both** lists are enabled. If only one source provides an allowlist, that list is respected. - **Precedence:** `excludeTools` always takes precedence over `includeTools`. This ensures you always have veto power over tools provided by an extension and that an extension cannot re-enable tools you have omitted from your personal allowlist. - **Environment variables:** The `env` objects are merged. If the same variable is defined in both places, your local value takes precedence. - **Scalar properties:** Properties like `command`, `url`, and `timeout` are replaced by your local values if provided. **Example override:** ```json { "mcpServers": { "google-workspace": { "excludeTools": ["gmail.send"] } } } ``` #### Server status (`MCPServerStatus`) - **`DISCONNECTED`:** Server is not connected or has errors - **`CONNECTING`:** Connection attempt in progress - **`CONNECTED`:** Server is connected and ready #### Discovery state (`MCPDiscoveryState`) - **`NOT_STARTED`:** Discovery hasn't begun - **`IN_PROGRESS`:** Currently discovering servers - **`COMPLETED`:** Discovery finished (with or without errors) ### Common issues and solutions #### Server won't connect **Symptoms:** Server shows `DISCONNECTED` status **Troubleshooting:** 1. **Check configuration:** Verify `command`, `args`, and `cwd` are correct 2. **Test manually:** Run the server command directly to ensure it works 3. **Check dependencies:** Ensure all required packages are installed 4. **Review logs:** Look for error messages in the CLI output 5. **Verify permissions:** Ensure the CLI can execute the server command #### No tools discovered **Symptoms:** Server connects but no tools are available **Troubleshooting:** 1. **Verify tool registration:** Ensure your server actually registers tools 2. **Check MCP protocol:** Confirm your server implements the MCP tool listing correctly 3. **Review server logs:** Check stderr output for server-side errors 4. **Test tool listing:** Manually test your server's tool discovery endpoint #### Tools not executing **Symptoms:** Tools are discovered but fail during execution **Troubleshooting:** 1. **Parameter validation:** Ensure your tool accepts the expected parameters 2. **Schema compatibility:** Verify your input schemas are valid JSON Schema 3. **Error handling:** Check if your tool is throwing unhandled exceptions 4. **Timeout issues:** Consider increasing the `timeout` setting #### Sandbox compatibility **Symptoms:** MCP servers fail when sandboxing is enabled **Solutions:** 1. **Docker-based servers:** Use Docker containers that include all dependencies 2. **Path accessibility:** Ensure server executables are available in the sandbox 3. **Network access:** Configure sandbox to allow necessary network connections 4. **Environment variables:** Verify required environment variables are passed through ### Debugging tips 1. **Enable debug mode:** Run the CLI with `--debug` for verbose output (use F12 to open debug console in interactive mode) 2. **Check stderr:** MCP server stderr is captured and logged (INFO messages filtered) 3. **Test isolation:** Test your MCP server independently before integrating 4. **Incremental setup:** Start with simple tools before adding complex functionality 5. **Use `/mcp` frequently:** Monitor server status during development ## Important notes ### Security considerations - **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use cautiously and only for servers you completely control - **Access tokens:** Be security-aware when configuring environment variables containing API keys or tokens. See [Security and environment sanitization](#security-and-environment-sanitization) for details on how Gemini CLI protects your credentials. - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are available within the sandbox environment - **Private data:** Using broadly scoped personal access tokens can lead to information leakage between repositories. ### Performance and resource management - **Connection persistence:** The CLI maintains persistent connections to servers that successfully register tools - **Automatic cleanup:** Connections to servers providing no tools are automatically closed - **Timeout management:** Configure appropriate timeouts based on your server's response characteristics - **Resource monitoring:** MCP servers run as separate processes and consume system resources ### Schema compatibility - **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Gemini API compatibility - **Name sanitization:** Tool names are automatically sanitized to meet API requirements - **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use. ## Returning rich content from tools MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn. All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information. ### How it works To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model. You can mix and match different content block types in the `content` array. The supported block types include: - `text` - `image` - `audio` - `resource` (embedded content) - `resource_link` ### Example: Returning text and an image Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image: ```json { "content": [ { "type": "text", "text": "Here is the logo you requested." }, { "type": "image", "data": "BASE64_ENCODED_IMAGE_DATA_HERE", "mimeType": "image/png" }, { "type": "text", "text": "The logo was created in 2025." } ] } ``` When the Gemini CLI receives this response, it will: 1. Extract all the text and combine it into a single `functionResponse` part for the model. 2. Present the image data as a separate `inlineData` part. 3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received. This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model. ## MCP prompts as slash commands In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name. ### Defining prompts on the server Here's a small example of a stdio MCP server that defines prompts: ```ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; const server = new McpServer({ name: 'prompt-server', version: '1.0.0', }); server.registerPrompt( 'poem-writer', { title: 'Poem Writer', description: 'Write a nice haiku', argsSchema: { title: z.string(), mood: z.string().optional() }, }, ({ title, mood }) => ({ messages: [ { role: 'user', content: { type: 'text', text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `, }, }, ], }), ); const transport = new StdioServerTransport(); await server.connect(transport); ``` This can be included in `settings.json` under `mcpServers` with: ```json { "mcpServers": { "nodeServer": { "command": "node", "args": ["filename.ts"] } } } ``` ### Invoking prompts Once a prompt is discovered, you can invoke it using its name as a slash command. The CLI will automatically handle parsing arguments. ```bash /poem-writer --title="Gemini CLI" --mood="reverent" ``` or, using positional arguments: ```bash /poem-writer "Gemini CLI" reverent ``` When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows. ## Managing MCP servers with `gemini mcp` While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files. ### Adding a server (`gemini mcp add`) The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file. **Command:** ```bash gemini mcp add [options] [args...] ``` - ``: A unique name for the server. - ``: The command to execute (for `stdio`) or the URL (for `http`/`sse`). - `[args...]`: Optional arguments for a `stdio` command. **Options (flags):** - `-s, --scope`: Configuration scope (user or project). [default: "project"] - `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"] - `-e, --env`: Set environment variables (e.g. -e KEY=value). - `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123"). - `--timeout`: Set connection timeout in milliseconds. - `--trust`: Trust the server (bypass all tool call confirmation prompts). - `--description`: Set the description for the server. - `--include-tools`: A comma-separated list of tools to include. - `--exclude-tools`: A comma-separated list of tools to exclude. #### Adding an stdio server This is the default transport for running local servers. ```bash # Basic syntax gemini mcp add [options] [args...] # Example: Adding a local server gemini mcp add -e API_KEY=123 -e DEBUG=true my-stdio-server /path/to/server arg1 arg2 arg3 # Example: Adding a local python server gemini mcp add python-server python server.py -- --server-arg my-value ``` #### Adding an HTTP server This transport is for servers that use the streamable HTTP transport. ```bash # Basic syntax gemini mcp add --transport http # Example: Adding an HTTP server gemini mcp add --transport http http-server https://api.example.com/mcp/ # Example: Adding an HTTP server with an authentication header gemini mcp add --transport http --header "Authorization: Bearer abc123" secure-http https://api.example.com/mcp/ ``` #### Adding an SSE server This transport is for servers that use Server-Sent Events (SSE). ```bash # Basic syntax gemini mcp add --transport sse # Example: Adding an SSE server gemini mcp add --transport sse sse-server https://api.example.com/sse/ # Example: Adding an SSE server with an authentication header gemini mcp add --transport sse --header "Authorization: Bearer abc123" secure-sse https://api.example.com/sse/ ``` ### Listing servers (`gemini mcp list`) To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status. This command has no flags. **Command:** ```bash gemini mcp list ``` > [!NOTE] > For security, `stdio` MCP servers (those using the > `command` property) are only tested and displayed as "Connected" if the > current folder is trusted. If the folder is untrusted, they will show as > "Disconnected". Use `gemini trust` to trust the current folder. **Example output:** ```sh ✓ stdio-server: command: python3 server.py (stdio) - Connected ✓ http-server: https://api.example.com/mcp (http) - Connected ✗ sse-server: https://api.example.com/sse (sse) - Disconnected ``` ## Troubleshooting and Diagnostics To minimize noise during startup, MCP connection errors for background servers are "silent by default." If issues are detected during startup, a single informational hint will be shown: _"MCP issues detected. Run /mcp list for status."_ Detailed, actionable diagnostics for a specific server are automatically re-enabled when: 1. You run an interactive command like `/mcp list`, `/mcp auth`, etc. 2. The model attempts to execute a tool from that server. 3. You invoke an MCP prompt from that server. You can also use `gemini mcp list` from your shell to see connection errors for all configured servers. ### Removing a server (`gemini mcp remove`) To delete a server from your configuration, use the `remove` command with the server's name. **Command:** ```bash gemini mcp remove ``` **Options (flags):** - `-s, --scope`: Configuration scope (user or project). [default: "project"] **Example:** ```bash gemini mcp remove my-server ``` This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`). ### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`) Temporarily disable an MCP server without removing its configuration, or re-enable a previously disabled server. **Commands:** ```bash gemini mcp enable [--session] gemini mcp disable [--session] ``` **Options (flags):** - `--session`: Apply change only for this session (not persisted to file). Disabled servers appear in `/mcp` status as "Disabled" but won't connect or provide tools. Enablement state is stored in `~/.gemini/mcp-server-enablement.json`. The same commands are available as slash commands during an active session: `/mcp enable ` and `/mcp disable `. ## Instructions Gemini CLI supports [MCP server instructions](https://modelcontextprotocol.io/specification/2025-06-18/schema#initializeresult), which will be appended to the system instructions. ================================================ FILE: docs/tools/memory.md ================================================ # Memory tool (`save_memory`) The `save_memory` tool allows the Gemini agent to persist specific facts, user preferences, and project details across sessions. ## Technical reference This tool appends information to the `## Gemini Added Memories` section of your global `GEMINI.md` file (typically located at `~/.gemini/GEMINI.md`). ### Arguments - `fact` (string, required): A clear, self-contained statement in natural language. ## Technical behavior - **Storage:** Appends to the global context file in the user's home directory. - **Loading:** The stored facts are automatically included in the hierarchical context system for all future sessions. - **Format:** Saves data as a bulleted list item within a dedicated Markdown section. ## Use cases - Persisting user preferences (for example, "I prefer functional programming"). - Saving project-wide architectural decisions. - Storing frequently used aliases or system configurations. ## Next steps - Follow the [Memory management guide](../cli/tutorials/memory-management.md) for practical examples. - Learn how the [Project context (GEMINI.md)](../cli/gemini-md.md) system loads this information. ================================================ FILE: docs/tools/planning.md ================================================ # Gemini CLI planning tools Planning tools let Gemini CLI switch into a safe, read-only "Plan Mode" for researching and planning complex changes, and to signal the finalization of a plan to the user. ## 1. `enter_plan_mode` (EnterPlanMode) `enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. > [!NOTE] > This tool is not available when the CLI is in YOLO mode. - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode - **File:** `enter-plan-mode.ts` - **Parameters:** - `reason` (string, optional): A short reason explaining why the agent is entering plan mode (for example, "Starting a complex feature implementation"). - **Behavior:** - Switches the CLI's approval mode to `PLAN`. - Notifies the user that the agent has entered Plan Mode. - **Output (`llmContent`):** A message indicating the switch, for example, `Switching to Plan mode.` - **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode. ## 2. `exit_plan_mode` (ExitPlanMode) `exit_plan_mode` signals that the planning phase is complete. It presents the finalized plan to the user and requests approval to start the implementation. - **Tool name:** `exit_plan_mode` - **Display name:** Exit Plan Mode - **File:** `exit-plan-mode.ts` - **Parameters:** - `plan_path` (string, required): The path to the finalized Markdown plan file. This file MUST be located within the project's temporary plans directory (for example, `~/.gemini/tmp//plans/`). - **Behavior:** - Validates that the `plan_path` is within the allowed directory and that the file exists and has content. - Presents the plan to the user for review. - If the user approves the plan: - Switches the CLI's approval mode to the user's chosen approval mode ( `DEFAULT` or `AUTO_EDIT`). - Marks the plan as approved for implementation. - If the user rejects the plan: - Stays in Plan Mode. - Returns user feedback to the model to refine the plan. - **Output (`llmContent`):** - On approval: A message indicating the plan was approved and the new approval mode. - On rejection: A message containing the user's feedback. - **Confirmation:** Yes. Shows the finalized plan and asks for user approval to proceed with implementation. ================================================ FILE: docs/tools/shell.md ================================================ # Shell tool (`run_shell_command`) The `run_shell_command` tool allows the Gemini model to execute commands directly on your system's shell. It is the primary mechanism for the agent to interact with your environment beyond simple file edits. ## Technical reference On Windows, commands execute with `powershell.exe -NoProfile -Command`. On other platforms, they execute with `bash -c`. ### Arguments - `command` (string, required): The exact shell command to execute. - `description` (string, optional): A brief description shown to the user for confirmation. - `dir_path` (string, optional): The absolute path or relative path from workspace root where the command runs. - `is_background` (boolean, optional): Whether to move the process to the background immediately after starting. ### Return values The tool returns a JSON object containing: - `Command`: The executed string. - `Directory`: The execution path. - `Stdout` / `Stderr`: The output streams. - `Exit Code`: The process return code. - `Background PIDs`: PIDs of any started background processes. ## Configuration You can configure the behavior of the `run_shell_command` tool by modifying your `settings.json` file or by using the `/settings` command in the Gemini CLI. ### Enabling interactive commands To enable interactive commands, you need to set the `tools.shell.enableInteractiveShell` setting to `true`. This will use `node-pty` for shell command execution, which allows for interactive sessions. If `node-pty` is not available, it will fall back to the `child_process` implementation, which does not support interactive commands. **Example `settings.json`:** ```json { "tools": { "shell": { "enableInteractiveShell": true } } } ``` ### Showing color in output To show color in the shell output, you need to set the `tools.shell.showColor` setting to `true`. This setting only applies when `tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** ```json { "tools": { "shell": { "showColor": true } } } ``` ### Setting the pager You can set a custom pager for the shell output by setting the `tools.shell.pager` setting. The default pager is `cat`. This setting only applies when `tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** ```json { "tools": { "shell": { "pager": "less" } } } ``` ## Interactive commands The `run_shell_command` tool now supports interactive commands by integrating a pseudo-terminal (pty). This allows you to run commands that require real-time user input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and interactive version control operations (`git rebase -i`). When an interactive command is running, you can send input to it from the Gemini CLI. To focus on the interactive shell, press `Tab`. The terminal output, including complex TUIs, will be rendered correctly. ## Important notes - **Security:** Be cautious when executing commands, especially those constructed from user input, to prevent security vulnerabilities. - **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully. - **Background processes:** When a command is run in the background with `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process. ## Environment variables When `run_shell_command` executes a command, it sets the `GEMINI_CLI=1` environment variable in the subprocess's environment. This allows scripts or tools to detect if they are being run from within the Gemini CLI. ## Command restrictions > [!WARNING] > The `tools.core` setting is an **allowlist for _all_ built-in > tools**, not just shell commands. When you set `tools.core` to any value, > _only_ the tools explicitly listed will be enabled. This includes all built-in > tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`, > `replace`, etc. You can restrict the commands that can be executed by the `run_shell_command` tool by using the `tools.core` and `tools.exclude` settings in your configuration file. - `tools.core`: To restrict `run_shell_command` to a specific set of commands, add entries to the `core` list under the `tools` category in the format `run_shell_command()`. For example, `"tools": {"core": ["run_shell_command(git)"]}` will only allow `git` commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked. - `tools.exclude` [DEPRECATED]: To block specific commands, use the [Policy Engine](../reference/policy-engine.md). Historically, this setting allowed adding entries to the `exclude` list under the `tools` category in the format `run_shell_command()`. For example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. The validation logic is designed to be secure and flexible: 1. **Command chaining disabled**: The tool automatically splits commands chained with `&&`, `||`, or `;` and validates each part separately. If any part of the chain is disallowed, the entire command is blocked. 2. **Prefix matching**: The tool uses prefix matching. For example, if you allow `git`, you can run `git status` or `git log`. 3. **Blocklist precedence**: The `tools.exclude` list is always checked first. If a command matches a blocked prefix, it will be denied, even if it also matches an allowed prefix in `tools.core`. ### Command restriction examples **Allow only specific command prefixes** To allow only `git` and `npm` commands, and block all others: ```json { "tools": { "core": ["run_shell_command(git)", "run_shell_command(npm)"] } } ``` - `git status`: Allowed - `npm install`: Allowed - `ls -l`: Blocked **Block specific command prefixes** To block `rm` and allow all other commands: ```json { "tools": { "core": ["run_shell_command"], "exclude": ["run_shell_command(rm)"] } } ``` - `rm -rf /`: Blocked - `git status`: Allowed - `npm install`: Allowed **Blocklist takes precedence** If a command prefix is in both `tools.core` and `tools.exclude`, it will be blocked. - **`tools.shell.enableInteractiveShell`**: (boolean) Uses `node-pty` for real-time interaction. - **`tools.shell.showColor`**: (boolean) Preserves ANSI colors in output. - **`tools.shell.inactivityTimeout`**: (number) Seconds to wait for output before killing the process. ### Command restrictions You can limit which commands the agent is allowed to request using these settings: - **`tools.core`**: An allowlist of command prefixes (for example, `["git", "npm test"]`). - **`tools.exclude`**: A blocklist of command prefixes. ## Use cases - Running build scripts and test suites. - Initializing or managing version control systems. - Installing project dependencies. - Starting development servers or background watchers. ## Next steps - Follow the [Shell commands tutorial](../cli/tutorials/shell-commands.md) for practical examples. - Learn about [Sandboxing](../cli/sandbox.md) to isolate command execution. ================================================ FILE: docs/tools/todos.md ================================================ # Todo tool (`write_todos`) The `write_todos` tool allows the Gemini agent to maintain an internal list of subtasks for multi-step requests. ## Technical reference The agent uses this tool to manage its execution plan and provide progress updates to the CLI interface. ### Arguments - `todos` (array of objects, required): The complete list of tasks. Each object includes: - `description` (string): Technical description of the task. - `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or `blocked`. ## Technical behavior - **Interface:** Updates the progress indicator above the CLI input prompt. - **Exclusivity:** Only one task can be marked `in_progress` at any time. - **Persistence:** Todo state is scoped to the current session. - **Interaction:** Users can toggle the full list view using **Ctrl+T**. ## Use cases - Breaking down a complex feature implementation into manageable steps. - Coordinating multi-file refactoring tasks. - Providing visibility into the agent's current focus during long-running tasks. ## Next steps - Follow the [Task planning tutorial](../cli/tutorials/task-planning.md) for usage details. - Learn about [Session management](../cli/session-management.md) for context. ================================================ FILE: docs/tools/web-fetch.md ================================================ # Web fetch tool (`web_fetch`) The `web_fetch` tool allows the Gemini agent to retrieve and process content from specific URLs provided in your prompt. ## Technical reference The agent uses this tool when you include URLs in your prompt and request specific operations like summarization or extraction. ### Arguments - `prompt` (string, required): A request containing up to 20 valid URLs (starting with `http://` or `https://`) and instructions on how to process them. ## Technical behavior - **Confirmation:** Triggers a confirmation dialog showing the converted URLs. - **Processing:** Uses the Gemini API's `urlContext` for retrieval. - **Fallback:** If API access fails, the tool attempts to fetch raw content directly from your local machine. - **Formatting:** Returns a synthesized response with source attribution. ## Use cases - Summarizing technical articles or blog posts. - Comparing data between two or more web pages. - Extracting specific information from a documentation site. ## Next steps - Follow the [Web tools guide](../cli/tutorials/web-tools.md) for practical usage examples. - See the [Web search tool reference](./web-search.md) for general queries. ================================================ FILE: docs/tools/web-search.md ================================================ # Web search tool (`google_web_search`) The `google_web_search` tool allows the Gemini agent to retrieve up-to-date information, news, and facts from the internet via Google Search. ## Technical reference The agent uses this tool when your request requires knowledge of current events or specific online documentation not available in its internal training data. ### Arguments - `query` (string, required): The search query to be executed. ## Technical behavior - **Grounding:** Returns a generated summary based on search results. - **Citations:** Includes source URIs and titles for factual grounding. - **Processing:** The Gemini API processes the search results before returning a synthesized response to the agent. ## Use cases - Researching the latest version of a software library or API. - Finding solutions to recent software bugs or security vulnerabilities. - Retrieving news or documentation updated after the model's knowledge cutoff. ## Next steps - Follow the [Web tools guide](../cli/tutorials/web-tools.md) for practical usage examples. - Explore the [Web fetch tool reference](./web-fetch.md) for direct URL access. ================================================ FILE: esbuild.config.js ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { writeFileSync } from 'node:fs'; import { wasmLoader } from 'esbuild-plugin-wasm'; let esbuild; try { esbuild = (await import('esbuild')).default; } catch (_error) { console.error('esbuild not available - cannot build bundle'); process.exit(1); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); const pkg = require(path.resolve(__dirname, 'package.json')); function createWasmPlugins() { const wasmBinaryPlugin = { name: 'wasm-binary', setup(build) { build.onResolve({ filter: /\.wasm\?binary$/ }, (args) => { const specifier = args.path.replace(/\?binary$/, ''); const resolveDir = args.resolveDir || ''; const isBareSpecifier = !path.isAbsolute(specifier) && !specifier.startsWith('./') && !specifier.startsWith('../'); let resolvedPath; if (isBareSpecifier) { resolvedPath = require.resolve(specifier, { paths: resolveDir ? [resolveDir, __dirname] : [__dirname], }); } else { resolvedPath = path.isAbsolute(specifier) ? specifier : path.join(resolveDir, specifier); } return { path: resolvedPath, namespace: 'wasm-embedded' }; }); }, }; return [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })]; } const external = [ '@lydell/node-pty', 'node-pty', '@lydell/node-pty-darwin-arm64', '@lydell/node-pty-darwin-x64', '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', 'keytar', '@google/gemini-cli-devtools', ]; const baseConfig = { bundle: true, platform: 'node', format: 'esm', external, loader: { '.node': 'file' }, write: true, }; const commonAliases = { punycode: 'punycode/', }; const cliConfig = { ...baseConfig, banner: { js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`, }, entryPoints: { gemini: 'packages/cli/index.ts' }, outdir: 'bundle', splitting: true, define: { __filename: '__chunk_filename', __dirname: '__chunk_dirname', 'process.env.CLI_VERSION': JSON.stringify(pkg.version), 'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify( pkg.config?.sandboxImageUri, ), }, plugins: createWasmPlugins(), alias: { 'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'), ...commonAliases, }, metafile: true, }; const a2aServerConfig = { ...baseConfig, banner: { js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`, }, entryPoints: ['packages/a2a-server/src/http/server.ts'], outfile: 'packages/a2a-server/dist/a2a-server.mjs', define: { __filename: '__chunk_filename', __dirname: '__chunk_dirname', 'process.env.CLI_VERSION': JSON.stringify(pkg.version), }, plugins: createWasmPlugins(), alias: commonAliases, }; Promise.allSettled([ esbuild.build(cliConfig).then(({ metafile }) => { if (process.env.DEV === 'true') { writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2)); } }), esbuild.build(a2aServerConfig), ]).then((results) => { const [cliResult, a2aResult] = results; if (cliResult.status === 'rejected') { console.error('gemini.js build failed:', cliResult.reason); process.exit(1); } // error in a2a-server bundling will not stop gemini.js bundling process if (a2aResult.status === 'rejected') { console.warn('a2a-server build failed:', a2aResult.reason); } }); ================================================ FILE: eslint.config.js ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import reactPlugin from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import prettierConfig from 'eslint-config-prettier'; import importPlugin from 'eslint-plugin-import'; import vitest from '@vitest/eslint-plugin'; import globals from 'globals'; import headers from 'eslint-plugin-headers'; import path from 'node:path'; import url from 'node:url'; // --- ESM way to get __dirname --- const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // --- --- // Determine the monorepo root (assuming eslint.config.js is at the root) const projectRoot = __dirname; const currentYear = new Date().getFullYear(); const commonRestrictedSyntaxRules = [ { selector: 'CallExpression[callee.name="require"]', message: 'Avoid using require(). Use ES6 imports instead.', }, { selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', message: 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }, ]; export default tseslint.config( { // Global ignores ignores: [ 'node_modules/*', 'eslint.config.js', 'packages/**/dist/**', 'bundle/**', 'package/bundle/**', '.integration-tests/**', 'dist/**', 'evals/**', 'packages/test-utils/**', '.gemini/skills/**', '**/*.d.ts', ], }, eslint.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactPlugin.configs.flat.recommended, reactPlugin.configs.flat['jsx-runtime'], // Add this if you are using React 17+ { // Settings for eslint-plugin-react settings: { react: { version: 'detect', }, }, }, { // Rules for packages/*/src (TS/TSX) files: ['packages/*/src/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, settings: { 'import/resolver': { node: true, }, }, languageOptions: { parser: tseslint.parser, parserOptions: { projectService: true, tsconfigRootDir: projectRoot, }, globals: { ...globals.node, ...globals.es2021, }, }, rules: { ...importPlugin.configs.recommended.rules, ...importPlugin.configs.typescript.rules, 'import/no-default-export': 'warn', 'import/no-unresolved': 'off', 'import/no-duplicates': 'error', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], curly: ['error', 'multi-line'], eqeqeq: ['error', 'always', { null: 'ignore' }], '@typescript-eslint/consistent-type-assertions': [ 'error', { assertionStyle: 'as' }, ], '@typescript-eslint/explicit-member-accessibility': [ 'error', { accessibility: 'no-public' }, ], '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-inferrable-types': [ 'error', { ignoreParameters: true, ignoreProperties: true }, ], '@typescript-eslint/consistent-type-imports': [ 'error', { disallowTypeAnnotations: false }, ], '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], // Prevent async errors from bypassing catch handlers '@typescript-eslint/return-await': ['error', 'in-try-catch'], 'import/no-internal-modules': 'off', 'import/no-relative-packages': 'error', 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', 'no-restricted-syntax': [ 'error', ...commonRestrictedSyntaxRules, { selector: 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', message: 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', }, ], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ // Enable TS version 'error', { allowShortCircuit: true, allowTernary: true }, ], 'no-var': 'error', 'object-shorthand': 'error', 'one-var': ['error', 'never'], 'prefer-arrow-callback': 'error', 'prefer-const': ['error', { destructuring: 'all' }], radix: 'error', 'no-console': 'error', 'default-case': 'error', '@typescript-eslint/await-thenable': ['error'], '@typescript-eslint/no-floating-promises': ['error'], '@typescript-eslint/no-unnecessary-type-assertion': ['error'], 'no-restricted-imports': [ 'error', { paths: [ { name: 'node:os', importNames: ['homedir', 'tmpdir'], message: 'Please use the helpers from @google/gemini-cli-core instead of node:os homedir()/tmpdir() to ensure strict environment isolation.', }, { name: 'os', importNames: ['homedir', 'tmpdir'], message: 'Please use the helpers from @google/gemini-cli-core instead of os homedir()/tmpdir() to ensure strict environment isolation.', }, ], }, ], }, }, { // API Response Optionality enforcement for Code Assist files: ['packages/core/src/code_assist/**/*.{ts,tsx}'], rules: { 'no-restricted-syntax': [ 'error', ...commonRestrictedSyntaxRules, { selector: 'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', message: 'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', }, { selector: 'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', message: 'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', }, ], }, }, { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], ignores: ['**/*.test.ts', '**/*.test.tsx', 'packages/*/src/test-utils/**'], rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-return': 'error', 'no-restricted-syntax': [ 'error', ...commonRestrictedSyntaxRules, { selector: 'CallExpression[callee.object.name="Object"][callee.property.name="create"]', message: 'Avoid using Object.create() in product code. Use object spread {...obj}, explicit class instantiation, structuredClone(), or copy constructors instead.', }, { selector: 'Identifier[name="Reflect"]', message: 'Avoid using Reflect namespace in product code. Do not use reflection to make copies. Instead, use explicit object copying or cloning (structuredClone() for values, new instance/clone function for classes).', }, ], }, }, { // Allow os.homedir() in tests and paths.ts where it is used to implement the helper files: [ '**/*.test.ts', '**/*.test.tsx', 'packages/core/src/utils/paths.ts', 'packages/test-utils/src/**/*.ts', 'scripts/**/*.js', ], rules: { 'no-restricted-imports': 'off', }, }, { // Prevent self-imports in packages files: ['packages/core/src/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { name: '@google/gemini-cli-core', message: 'Please use relative imports within the @google/gemini-cli-core package.', }, ], }, }, { files: ['packages/cli/src/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { name: '@google/gemini-cli', message: 'Please use relative imports within the @google/gemini-cli package.', }, ], }, }, { files: ['packages/sdk/src/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { name: '@google/gemini-cli-sdk', message: 'Please use relative imports within the @google/gemini-cli-sdk package.', }, ], }, }, { files: ['packages/*/src/**/*.test.{ts,tsx}'], plugins: { vitest, }, rules: { ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], }, }, { files: ['./**/*.{tsx,ts,js,cjs}'], plugins: { headers, import: importPlugin, }, rules: { 'headers/header-format': [ 'error', { source: 'string', content: [ '@license', 'Copyright (year) Google LLC', 'SPDX-License-Identifier: Apache-2.0', ].join('\n'), patterns: { year: { pattern: `202[5-${currentYear.toString().slice(-1)}]`, defaultValue: currentYear.toString(), }, }, }, ], 'import/enforce-node-protocol-usage': ['error', 'always'], }, }, { files: [ './scripts/**/*.js', 'packages/*/scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}', ], languageOptions: { globals: { ...globals.node, process: 'readonly', console: 'readonly', }, }, rules: { '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], }, }, { files: ['**/*.cjs'], languageOptions: { sourceType: 'commonjs', globals: { ...globals.node, }, }, rules: { 'no-restricted-syntax': 'off', 'no-console': 'off', 'no-empty': 'off', 'no-redeclare': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], }, }, { files: ['packages/vscode-ide-companion/esbuild.js'], languageOptions: { globals: { ...globals.node, process: 'readonly', console: 'readonly', }, }, rules: { 'no-restricted-syntax': 'off', '@typescript-eslint/no-require-imports': 'off', }, }, // Examples should have access to standard globals like fetch { files: ['packages/cli/src/commands/extensions/examples/**/*.js'], languageOptions: { globals: { ...globals.node, fetch: 'readonly', }, }, }, // extra settings for scripts that we run directly with node { files: ['packages/vscode-ide-companion/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, process: 'readonly', console: 'readonly', }, }, rules: { 'no-restricted-syntax': 'off', '@typescript-eslint/no-require-imports': 'off', }, }, // Prettier config must be last prettierConfig, // extra settings for scripts that we run directly with node { files: ['./integration-tests/**/*.js'], languageOptions: { globals: { ...globals.node, process: 'readonly', console: 'readonly', }, }, rules: { '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], }, }, ); ================================================ FILE: evals/README.md ================================================ # Behavioral Evals Behavioral evaluations (evals) are tests designed to validate the agent's behavior in response to specific prompts. They serve as a critical feedback loop for changes to system prompts, tool definitions, and other model-steering mechanisms, and as a tool for assessing feature reliability by model, and preventing regressions. ## Why Behavioral Evals? Unlike traditional **integration tests** which verify that the system functions correctly (e.g., "does the file writer actually write to disk?"), behavioral evals verify that the model _chooses_ to take the correct action (e.g., "does the model decide to write to disk when asked to save code?"). They are also distinct from broad **industry benchmarks** (like SWE-bench). While benchmarks measure general capabilities across complex challenges, our behavioral evals focus on specific, granular behaviors relevant to the Gemini CLI's features. ### Key Characteristics - **Feedback Loop**: They help us understand how changes to prompts or tools affect the model's decision-making. - _Did a change to the system prompt make the model less likely to use tool X?_ - _Did a new tool definition confuse the model?_ - **Regression Testing**: They prevent regressions in model steering. - **Non-Determinism**: Unlike unit tests, LLM behavior can be non-deterministic. We distinguish between behaviors that should be robust (`ALWAYS_PASSES`) and those that are generally reliable but might occasionally vary (`USUALLY_PASSES`). ## Best Practices When designing behavioral evals, aim for scenarios that accurately reflect real-world usage while remaining small and maintainable. - **Realistic Complexity**: Evals should be complicated enough to be "realistic." They should operate on actual files and a source directory, mirroring how a real agent interacts with a workspace. Remember that the agent may behave differently in a larger codebase, so we want to avoid scenarios that are too simple to be realistic. - _Good_: An eval that provides a small, functional React component and asks the agent to add a specific feature, requiring it to read the file, understand the context, and write the correct changes. - _Bad_: An eval that simply asks the agent a trivia question or asks it to write a generic script without providing any local workspace context. - **Maintainable Size**: Evals should be small enough to reason about and maintain. We probably can't check in an entire repo as a test case, though over time we will want these evals to mature into more and more realistic scenarios. - _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and a test file) that isolates the specific behavior being evaluated. - _Bad_: A test setup containing dozens of files from a complex framework where the setup logic itself is prone to breaking. - **Unambiguous and Reliable Assertions**: Assertions must be clear and specific to ensure the test passes for the right reason. - _Good_: Checking that a modified file contains a specific AST node or exact string, or verifying that a tool was called with with the right parameters. - _Bad_: Only checking for a tool call, which could happen for an unrelated reason. Expecting specific LLM output. - **Fail First**: Have tests that failed before your prompt or tool change. We want to be sure the test fails before your "fix". It's pretty easy to accidentally create a passing test that asserts behaviors we get for free. In general, every eval should be accompanied by prompt change, and most prompt changes should be accompanied by an eval. - _Good_: Observing a failure, writing an eval that reliably reproduces the failure, modifying the prompt/tool, and then verifying the eval passes. - _Bad_: Writing an eval that passes on the first run and assuming your new prompt change was responsible. - **Less is More**: Prefer fewer, more realistic tests that assert the major paths vs. more tests that are more unit-test like. These are evals, so the value is in testing how the agent works in a semi-realistic scenario. ## Creating an Evaluation Evaluations are located in the `evals` directory. Each evaluation is a Vitest test file that uses the `evalTest` function from `evals/test-helper.ts`. ### `evalTest` The `evalTest` function is a helper that runs a single evaluation case. It takes two arguments: 1. `policy`: The consistency expectation for this test (`'ALWAYS_PASSES'` or `'USUALLY_PASSES'`). 2. `evalCase`: An object defining the test case. #### Policies Policies control how strictly a test is validated. - `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically trivial and test basic functionality. These run in every CI and can block PRs on failure. - `USUALLY_PASSES`: Tests expected to pass most of the time but may have some flakiness due to non-deterministic behaviors. These are run nightly and used to track the health of the product from build to build. **All new behavioral evaluations must be created with the `USUALLY_PASSES` policy.** A subset that prove to be highly stable over time may be promoted to `ALWAYS_PASSES`. For more information, see [Test promotion process](#test-promotion-process). #### `EvalCase` Properties - `name`: The name of the evaluation case. - `prompt`: The prompt to send to the model. - `params`: An optional object with parameters to pass to the test rig (e.g., settings). - `assert`: An async function that takes the test rig and the result of the run and asserts that the result is correct. - `log`: An optional boolean that, if set to `true`, will log the tool calls to a file in the `evals/logs` directory. ### Example ```typescript import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('my_feature', () => { // New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval evalTest('USUALLY_PASSES', { name: 'should do something', prompt: 'do it', assert: async (rig, result) => { // assertions }, }); }); ``` ## Running Evaluations First, build the bundled Gemini CLI. You must do this after every code change. ```bash npm run build npm run bundle ``` ### Always Passing Evals To run the evaluations that are expected to always pass (CI safe): ```bash npm run test:always_passing_evals ``` ### All Evals To run all evaluations, including those that may be flaky ("usually passes"): ```bash npm run test:all_evals ``` This command sets the `RUN_EVALS` environment variable to `1`, which enables the `USUALLY_PASSES` tests. ## Ensuring Eval is Stable Prior to Check-in The [Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) run is considered to be the source of truth for the quality of an eval test. Each run of it executes a test 3 times in a row, for each supported model. The result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many of the individual executions passed. Googlers can schedule a manual run against their branch by clicking the link above. Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini 3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the time before they are promoted. ## Test promotion process To maintain a stable and reliable CI, all new behavioral evaluations follow a mandatory deflaking process. 1. **Incubation**: You must create all new tests with the `USUALLY_PASSES` policy. This lets them be monitored in the nightly runs without blocking PRs. 2. **Monitoring**: The test must complete at least 10 nightly runs across all supported models. 3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the `/promote-behavioral-eval` slash command. This command verifies the 100% success rate requirement is met across many runs before updating the test policy. This promotion process is essential for preventing the introduction of flaky evaluations into the CI. ## Reporting Results for evaluations are available on GitHub Actions: - **CI Evals**: Included in the [E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml) workflow. These must pass 100% for every PR. - **Nightly Evals**: Run daily via the [Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) workflow. These track the long-term health and stability of model steering. ### Nightly Report Format The nightly workflow executes the full evaluation suite multiple times (currently 3 attempts) to account for non-determinism. These results are aggregated into a **Nightly Summary** attached to the workflow run. #### How to interpret the report: - **Pass Rate (%)**: Each cell represents the percentage of successful runs for a specific test in that workflow instance. - **History**: The table shows the pass rates for the last 7 nightly runs, allowing you to identify if a model's behavior is trending towards instability. - **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. A significant drop in the pass rate for a `USUALLY_PASSES` test—even if it doesn't drop to 0%—often indicates that a recent change to a system prompt or tool definition has made the model's behavior less reliable. ## Fixing Evaluations If an evaluation is failing or has a regressed pass rate, you can use the `/fix-behavioral-eval` command within Gemini CLI to help investigate and fix the issue. ### `/fix-behavioral-eval` This command is designed to automate the investigation and fixing process for failing evaluations. It will: 1. **Investigate**: Fetch the latest results from the nightly workflow using the `gh` CLI, identify the failing test, and review test trajectory logs in `evals/logs`. 2. **Fix**: Suggest and apply targeted fixes to the prompt or tool definitions. It prioritizes minimal changes to `prompt.ts`, tool instructions, and modules that contribute to the prompt. It generally tries to avoid changing the test itself. 3. **Verify**: Re-run the test 3 times across multiple models (e.g., Gemini 3.0, Gemini 3 Flash, Gemini 2.5 Pro) to ensure stability and calculate a success rate. 4. **Report**: Provide a summary of the success rate for each model and details on the applied fixes. To use it, run: ```bash gemini /fix-behavioral-eval ``` You can also provide a link to a specific GitHub Action run or the name of a specific test to focus the investigation: ```bash gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/runs/123456789 ``` When investigating failures manually, you can also enable verbose agent logs by setting the `GEMINI_DEBUG_LOG_FILE` environment variable. ### Best practices It's highly recommended to manually review and/or ask the agent to iterate on any prompt changes, even if they pass all evals. The prompt should prefer positive traits ('do X') and resort to negative traits ('do not do X') only when unable to accomplish the goal with positive traits. Gemini is quite good at instrospecting on its prompt when asked the right questions. ## Promoting evaluations Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` exclusively using the `/promote-behavioral-eval` slash command. Manual promotion is not allowed to ensure that the 100% success rate requirement is empirically met. ### `/promote-behavioral-eval` This command automates the promotion of stable tests by: 1. **Investigating**: Analyzing the results of the last 7 nightly runs on the `main` branch using the `gh` CLI. 2. **Criteria Check**: Identifying tests that have passed 100% of the time for ALL enabled models across the entire 7-run history. 3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to `ALWAYS_PASSES`. 4. **Verification**: Running the promoted test locally to ensure correctness. To run it: ```bash gemini /promote-behavioral-eval ``` ================================================ FILE: evals/answer-vs-act.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import { EDIT_TOOL_NAMES } from '@google/gemini-cli-core'; const FILES = { 'app.ts': 'const add = (a: number, b: number) => a - b;', 'package.json': '{"name": "test-app", "version": "1.0.0"}', } as const; describe('Answer vs. ask eval', () => { /** * Ensures that when the user asks to "inspect" for bugs, the agent does NOT * automatically modify the file, but instead asks for permission. */ evalTest('USUALLY_PASSES', { name: 'should not edit files when asked to inspect for bugs', prompt: 'Inspect app.ts for bugs', files: FILES, assert: async (rig, result) => { const toolLogs = rig.readToolLogs(); // Verify NO edit tools called const editCalls = toolLogs.filter((log) => EDIT_TOOL_NAMES.has(log.toolRequest.name), ); expect(editCalls.length).toBe(0); // Verify file unchanged const content = rig.readFile('app.ts'); expect(content).toContain('a - b'); }, }); /** * Ensures that when the user explicitly asks to "fix" a bug, the agent * does modify the file. */ evalTest('USUALLY_PASSES', { name: 'should edit files when asked to fix bug', prompt: 'Fix the bug in app.ts - it should add numbers not subtract', files: FILES, assert: async (rig) => { const toolLogs = rig.readToolLogs(); // Verify edit tools WERE called const editCalls = toolLogs.filter( (log) => EDIT_TOOL_NAMES.has(log.toolRequest.name) && log.toolRequest.success, ); expect(editCalls.length).toBeGreaterThanOrEqual(1); // Verify file changed const content = rig.readFile('app.ts'); expect(content).toContain('a + b'); }, }); /** * Ensures that when the user asks "any bugs?" the agent does NOT * automatically modify the file, but instead asks for permission. */ evalTest('USUALLY_PASSES', { name: 'should not edit when asking "any bugs"', prompt: 'Any bugs in app.ts?', files: FILES, assert: async (rig) => { const toolLogs = rig.readToolLogs(); // Verify NO edit tools called const editCalls = toolLogs.filter((log) => EDIT_TOOL_NAMES.has(log.toolRequest.name), ); expect(editCalls.length).toBe(0); // Verify file unchanged const content = rig.readFile('app.ts'); expect(content).toContain('a - b'); }, }); /** * Ensures that when the user asks a general question, the agent does NOT * automatically modify the file. */ evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked a general question', prompt: 'How does app.ts work?', files: FILES, assert: async (rig) => { const toolLogs = rig.readToolLogs(); // Verify NO edit tools called const editCalls = toolLogs.filter((log) => EDIT_TOOL_NAMES.has(log.toolRequest.name), ); expect(editCalls.length).toBe(0); // Verify file unchanged const content = rig.readFile('app.ts'); expect(content).toContain('a - b'); }, }); /** * Ensures that when the user asks a question about style, the agent does NOT * automatically modify the file. */ evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked about style', prompt: 'Is app.ts following good style?', files: FILES, assert: async (rig, result) => { const toolLogs = rig.readToolLogs(); // Verify NO edit tools called const editCalls = toolLogs.filter((log) => EDIT_TOOL_NAMES.has(log.toolRequest.name), ); expect(editCalls.length).toBe(0); // Verify file unchanged const content = rig.readFile('app.ts'); expect(content).toContain('a - b'); }, }); /** * Ensures that when the user points out an issue but doesn't ask for a fix, * the agent does NOT automatically modify the file. */ evalTest('USUALLY_PASSES', { name: 'should not edit files when user notes an issue', prompt: 'The add function subtracts numbers.', files: FILES, params: { timeout: 20000 }, // 20s timeout assert: async (rig) => { const toolLogs = rig.readToolLogs(); // Verify NO edit tools called const editCalls = toolLogs.filter((log) => EDIT_TOOL_NAMES.has(log.toolRequest.name), ); expect(editCalls.length).toBe(0); // Verify file unchanged const content = rig.readFile('app.ts'); expect(content).toContain('a - b'); }, }); }); ================================================ FILE: evals/app-test-helper.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { AppRig } from '../packages/cli/src/test-utils/AppRig.js'; import { type EvalPolicy, runEval, prepareLogDir, symlinkNodeModules, } from './test-helper.js'; import fs from 'node:fs'; import path from 'node:path'; import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core'; export interface AppEvalCase { name: string; configOverrides?: any; prompt: string; timeout?: number; files?: Record; setup?: (rig: AppRig) => Promise; assert: (rig: AppRig, output: string) => Promise; } /** * A helper for running behavioral evaluations using the in-process AppRig. * This matches the API of evalTest in test-helper.ts as closely as possible. */ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) { const fn = async () => { const rig = new AppRig({ configOverrides: { model: DEFAULT_GEMINI_MODEL, ...evalCase.configOverrides, }, }); const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); const logFile = path.join(logDir, `${sanitizedName}.log`); try { await rig.initialize(); const testDir = rig.getTestDir(); symlinkNodeModules(testDir); // Setup initial files if (evalCase.files) { for (const [filePath, content] of Object.entries(evalCase.files)) { const fullPath = path.join(testDir, filePath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content); } } // Run custom setup if provided (e.g. for breakpoints) if (evalCase.setup) { await evalCase.setup(rig); } // Render the app! rig.render(); // Wait for initial ready state await rig.waitForIdle(); // Send the initial prompt await rig.sendMessage(evalCase.prompt); // Run assertion. Interaction-heavy tests can do their own waiting/steering here. const output = rig.getStaticOutput(); await evalCase.assert(rig, output); } finally { const output = rig.getStaticOutput(); if (output) { await fs.promises.writeFile(logFile, output); } await rig.unmount(); } }; runEval(policy, evalCase.name, fn, (evalCase.timeout ?? 60000) + 10000); } ================================================ FILE: evals/ask_user.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { appEvalTest, AppEvalCase } from './app-test-helper.js'; import { EvalPolicy } from './test-helper.js'; function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) { return appEvalTest(policy, { ...evalCase, configOverrides: { ...evalCase.configOverrides, general: { ...evalCase.configOverrides?.general, approvalMode: 'default', enableAutoUpdate: false, enableAutoUpdateNotification: false, }, }, files: { ...evalCase.files, }, }); } describe('ask_user', () => { askUserEvalTest('USUALLY_PASSES', { name: 'Agent uses AskUser tool to present multiple choice options', prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`, setup: async (rig) => { rig.setBreakpoint(['ask_user']); }, assert: async (rig) => { const confirmation = await rig.waitForPendingConfirmation('ask_user'); expect( confirmation, 'Expected a pending confirmation for ask_user tool', ).toBeDefined(); }, }); askUserEvalTest('USUALLY_PASSES', { name: 'Agent uses AskUser tool to clarify ambiguous requirements', files: { 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }), }, prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`, setup: async (rig) => { rig.setBreakpoint(['ask_user']); }, assert: async (rig) => { const confirmation = await rig.waitForPendingConfirmation('ask_user'); expect( confirmation, 'Expected a pending confirmation for ask_user tool', ).toBeDefined(); }, }); askUserEvalTest('USUALLY_PASSES', { name: 'Agent uses AskUser tool before performing significant ambiguous rework', files: { 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";', 'packages/core/src/util.ts': '// util\nexport function help() {}', 'packages/core/package.json': JSON.stringify({ name: '@google/gemini-cli-core', }), 'README.md': '# Gemini CLI', }, prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`, setup: async (rig) => { rig.setBreakpoint(['enter_plan_mode', 'ask_user']); }, assert: async (rig) => { // It might call enter_plan_mode first. let confirmation = await rig.waitForPendingConfirmation([ 'enter_plan_mode', 'ask_user', ]); expect(confirmation, 'Expected a tool call confirmation').toBeDefined(); if (confirmation?.name === 'enter_plan_mode') { rig.acceptConfirmation('enter_plan_mode'); confirmation = await rig.waitForPendingConfirmation('ask_user'); } expect( confirmation?.toolName, 'Expected ask_user to be called to clarify the significant rework', ).toBe('ask_user'); }, }); // --- Regression Tests for Recent Fixes --- // Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to // confirm shell commands. Fixed via prompt refinements and tool definition // updates to clarify that shell command confirmation is handled by the UI. // See fix: https://github.com/google-gemini/gemini-cli/pull/20504 askUserEvalTest('USUALLY_PASSES', { name: 'Agent does NOT use AskUser to confirm shell commands', files: { 'package.json': JSON.stringify({ scripts: { build: 'echo building' }, }), }, prompt: `Run 'npm run build' in the current directory.`, setup: async (rig) => { rig.setBreakpoint(['run_shell_command', 'ask_user']); }, assert: async (rig) => { const confirmation = await rig.waitForPendingConfirmation([ 'run_shell_command', 'ask_user', ]); expect( confirmation, 'Expected a pending confirmation for a tool', ).toBeDefined(); expect( confirmation?.toolName, 'ask_user should not be called to confirm shell commands', ).toBe('run_shell_command'); }, }); }); ================================================ FILE: evals/automated-tool-use.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('Automated tool use', () => { /** * Tests that the agent always utilizes --fix when calling eslint. * We provide a 'lint' script in the package.json, which helps elicit * a repro by guiding the agent into using the existing deficient script. */ evalTest('USUALLY_PASSES', { name: 'should use automated tools (eslint --fix) to fix code style issues', files: { 'package.json': JSON.stringify( { name: 'typescript-project', version: '1.0.0', type: 'module', scripts: { lint: 'eslint .', }, devDependencies: { eslint: '^9.0.0', globals: '^15.0.0', typescript: '^5.0.0', 'typescript-eslint': '^8.0.0', '@eslint/js': '^9.0.0', }, }, null, 2, ), 'eslint.config.js': ` import globals from "globals"; import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; export default [ { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.node } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, { rules: { "prefer-const": "error", "@typescript-eslint/no-unused-vars": "off" } } ]; `, 'src/app.ts': ` export function main() { let count = 10; console.log(count); } `, }, prompt: 'Fix the linter errors in this project. Make sure to avoid interactive commands.', assert: async (rig) => { // Check if run_shell_command was used with --fix const toolCalls = rig.readToolLogs(); const shellCommands = toolCalls.filter( (call) => call.toolRequest.name === 'run_shell_command', ); const hasFixCommand = shellCommands.some((call) => { let args = call.toolRequest.args; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { return false; } } const cmd = (args as any)['command']; return ( cmd && (cmd.includes('eslint') || cmd.includes('npm run lint')) && cmd.includes('--fix') ); }); expect( hasFixCommand, 'Expected agent to use eslint --fix via run_shell_command', ).toBe(true); }, }); /** * Tests that the agent uses prettier --write to fix formatting issues in files * instead of trying to edit the files itself. */ evalTest('USUALLY_PASSES', { name: 'should use automated tools (prettier --write) to fix formatting issues', files: { 'package.json': JSON.stringify( { name: 'typescript-project', version: '1.0.0', type: 'module', scripts: {}, devDependencies: { prettier: '^3.0.0', typescript: '^5.0.0', }, }, null, 2, ), '.prettierrc': JSON.stringify( { semi: true, singleQuote: true, }, null, 2, ), 'src/app.ts': ` export function main() { const data={ name:'test', val:123 } console.log(data) } `, }, prompt: 'Fix the formatting errors in this project. Make sure to avoid interactive commands.', assert: async (rig) => { // Check if run_shell_command was used with --write const toolCalls = rig.readToolLogs(); const shellCommands = toolCalls.filter( (call) => call.toolRequest.name === 'run_shell_command', ); const hasFixCommand = shellCommands.some((call) => { let args = call.toolRequest.args; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { return false; } } const cmd = (args as any)['command']; return ( cmd && cmd.includes('prettier') && (cmd.includes('--write') || cmd.includes('-w')) ); }); expect( hasFixCommand, 'Expected agent to use prettier --write via run_shell_command', ).toBe(true); }, }); }); ================================================ FILE: evals/concurrency-safety.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect } from 'vitest'; import { evalTest } from './test-helper.js'; const MUTATION_AGENT_DEFINITION = `--- name: mutation-agent description: An agent that modifies the workspace (writes, deletes, git operations, etc). max_turns: 1 tools: - write_file --- You are the mutation agent. Do the mutation requested. `; describe('concurrency safety eval test cases', () => { evalTest('USUALLY_PASSES', { name: 'mutation agents are run in parallel when explicitly requested', params: { settings: { experimental: { enableAgents: true, }, }, }, prompt: 'Update A.txt to say "A" and update B.txt to say "B". Delegate these tasks to two separate mutation-agent subagents. You MUST run these subagents in parallel at the same time.', files: { '.gemini/agents/mutation-agent.md': MUTATION_AGENT_DEFINITION, }, assert: async (rig) => { const logs = rig.readToolLogs(); const mutationCalls = logs.filter( (log) => log.toolRequest?.name === 'mutation-agent', ); expect( mutationCalls.length, 'Agent should have called the mutation-agent at least twice', ).toBeGreaterThanOrEqual(2); const firstPromptId = mutationCalls[0].toolRequest.prompt_id; const secondPromptId = mutationCalls[1].toolRequest.prompt_id; expect( firstPromptId, 'mutation agents should be called in parallel (same turn / prompt_ids) when explicitly requested', ).toEqual(secondPromptId); }, }); }); ================================================ FILE: evals/edit-locations-eval.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('Edits location eval', () => { /** * Ensure that Gemini CLI always updates existing test files, if present, * instead of creating a new one. */ evalTest('USUALLY_PASSES', { name: 'should update existing test file instead of creating a new one', files: { 'package.json': JSON.stringify( { name: 'test-location-repro', version: '1.0.0', scripts: { test: 'vitest run', }, devDependencies: { vitest: '^1.0.0', typescript: '^5.0.0', }, }, null, 2, ), 'src/math.ts': ` export function add(a: number, b: number): number { return a + b; } export function subtract(a: number, b: number): number { return a - b; } export function multiply(a: number, b: number): number { return a + b; } `, 'src/math.test.ts': ` import { expect, test } from 'vitest'; import { add, subtract } from './math'; test('add adds two numbers', () => { expect(add(2, 3)).toBe(5); }); test('subtract subtracts two numbers', () => { expect(subtract(5, 3)).toBe(2); }); `, 'src/utils.ts': ` export function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } `, 'src/utils.test.ts': ` import { expect, test } from 'vitest'; import { capitalize } from './utils'; test('capitalize capitalizes the first letter', () => { expect(capitalize('hello')).toBe('Hello'); }); `, }, prompt: 'Fix the bug in src/math.ts. Do not run the code.', timeout: 180000, assert: async (rig) => { const toolLogs = rig.readToolLogs(); const replaceCalls = toolLogs.filter( (t) => t.toolRequest.name === 'replace', ); const writeFileCalls = toolLogs.filter( (t) => t.toolRequest.name === 'write_file', ); expect(replaceCalls.length).toBeGreaterThan(0); expect( writeFileCalls.some((file) => file.toolRequest.args.includes('.test.ts'), ), ).toBe(false); const targetFiles = replaceCalls.map((t) => { try { return JSON.parse(t.toolRequest.args).file_path; } catch { return null; } }); console.log('DEBUG: targetFiles', targetFiles); expect( new Set(targetFiles).size, 'Expected only two files changed', ).greaterThanOrEqual(2); expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true); expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe( true, ); }, }); }); ================================================ FILE: evals/frugalReads.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import { READ_FILE_TOOL_NAME, EDIT_TOOL_NAME } from '@google/gemini-cli-core'; describe('Frugal reads eval', () => { /** * Ensures that the agent is frugal in its use of context by relying * primarily on ranged reads when the line number is known, and combining * nearby ranges into a single contiguous read to save tool calls. */ evalTest('USUALLY_PASSES', { name: 'should use ranged read when nearby lines are targeted', files: { 'package.json': JSON.stringify({ name: 'test-project', version: '1.0.0', type: 'module', }), 'eslint.config.mjs': `export default [ { files: ["**/*.ts"], rules: { "no-var": "error" } } ];`, 'linter_mess.ts': (() => { const lines = []; for (let i = 0; i < 1000; i++) { if (i === 500 || i === 510 || i === 520) { lines.push(`var oldVar${i} = "needs fix";`); } else { lines.push(`const goodVar${i} = "clean";`); } } return lines.join('\n'); })(), }, prompt: 'Fix all linter errors in linter_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.', assert: async (rig) => { const logs = rig.readToolLogs(); // Check if the agent read the whole file const readCalls = logs.filter( (log) => log.toolRequest?.name === READ_FILE_TOOL_NAME, ); const targetFileReads = readCalls.filter((call) => { const args = JSON.parse(call.toolRequest.args); return args.file_path.includes('linter_mess.ts'); }); expect( targetFileReads.length, 'Agent should have used read_file to check context', ).toBeGreaterThan(0); // We expect 1-3 ranges in a single turn. expect( targetFileReads.length, 'Agent should have used 1-3 ranged reads for near errors', ).toBeLessThanOrEqual(3); const firstPromptId = targetFileReads[0].toolRequest.prompt_id; expect(firstPromptId, 'Prompt ID should be defined').toBeDefined(); expect( targetFileReads.every( (call) => call.toolRequest.prompt_id === firstPromptId, ), 'All reads should have happened in the same turn', ).toBe(true); let totalLinesRead = 0; const readRanges: { start_line: number; end_line: number }[] = []; for (const call of targetFileReads) { const args = JSON.parse(call.toolRequest.args); expect( args.end_line, 'Agent read the entire file (missing end_line) instead of using ranged read', ).toBeDefined(); const end_line = args.end_line; const start_line = args.start_line ?? 1; const linesRead = end_line - start_line + 1; totalLinesRead += linesRead; readRanges.push({ start_line, end_line }); expect(linesRead, 'Agent read too many lines at once').toBeLessThan( 1001, ); } // Ranged read shoud be frugal and just enough to satisfy the task at hand. expect( totalLinesRead, 'Agent read more of the file than expected', ).toBeLessThan(1000); // Check that we read around the error lines const errorLines = [500, 510, 520]; for (const line of errorLines) { const covered = readRanges.some( (range) => line >= range.start_line && line <= range.end_line, ); expect(covered, `Agent should have read around line ${line}`).toBe( true, ); } const editCalls = logs.filter( (log) => log.toolRequest?.name === EDIT_TOOL_NAME, ); const targetEditCalls = editCalls.filter((call) => { const args = JSON.parse(call.toolRequest.args); return args.file_path.includes('linter_mess.ts'); }); expect( targetEditCalls.length, 'Agent should have made replacement calls on the target file', ).toBeGreaterThanOrEqual(3); }, }); /** * Ensures the agent uses multiple ranged reads when the targets are far * apart to avoid the need to read the whole file. */ evalTest('USUALLY_PASSES', { name: 'should use ranged read when targets are far apart', files: { 'package.json': JSON.stringify({ name: 'test-project', version: '1.0.0', type: 'module', }), 'eslint.config.mjs': `export default [ { files: ["**/*.ts"], rules: { "no-var": "error" } } ];`, 'far_mess.ts': (() => { const lines = []; for (let i = 0; i < 1000; i++) { if (i === 100 || i === 900) { lines.push(`var oldVar${i} = "needs fix";`); } else { lines.push(`const goodVar${i} = "clean";`); } } return lines.join('\n'); })(), }, prompt: 'Fix all linter errors in far_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.', assert: async (rig) => { const logs = rig.readToolLogs(); const readCalls = logs.filter( (log) => log.toolRequest?.name === READ_FILE_TOOL_NAME, ); const targetFileReads = readCalls.filter((call) => { const args = JSON.parse(call.toolRequest.args); return args.file_path.includes('far_mess.ts'); }); // The agent should use ranged reads to be frugal with context tokens, // even if it requires multiple calls for far-apart errors. expect( targetFileReads.length, 'Agent should have used read_file to check context', ).toBeGreaterThan(0); // We allow multiple calls since the errors are far apart. expect( targetFileReads.length, 'Agent should have used separate reads for far apart errors', ).toBeLessThanOrEqual(4); for (const call of targetFileReads) { const args = JSON.parse(call.toolRequest.args); expect( args.end_line, 'Agent should have used ranged read (end_line) to save tokens', ).toBeDefined(); } }, }); /** * Validates that the agent reads the entire file if there are lots of matches * (e.g.: 10), as it's more efficient than many small ranged reads. */ evalTest('USUALLY_PASSES', { name: 'should read the entire file when there are many matches', files: { 'package.json': JSON.stringify({ name: 'test-project', version: '1.0.0', type: 'module', }), 'eslint.config.mjs': `export default [ { files: ["**/*.ts"], rules: { "no-var": "error" } } ];`, 'many_mess.ts': (() => { const lines = []; for (let i = 0; i < 1000; i++) { if (i % 100 === 0) { lines.push(`var oldVar${i} = "needs fix";`); } else { lines.push(`const goodVar${i} = "clean";`); } } return lines.join('\n'); })(), }, prompt: 'Fix all linter errors in many_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.', assert: async (rig) => { const logs = rig.readToolLogs(); const readCalls = logs.filter( (log) => log.toolRequest?.name === READ_FILE_TOOL_NAME, ); const targetFileReads = readCalls.filter((call) => { const args = JSON.parse(call.toolRequest.args); return args.file_path.includes('many_mess.ts'); }); expect( targetFileReads.length, 'Agent should have used read_file to check context', ).toBeGreaterThan(0); // In this case, we expect the agent to realize there are many scattered errors // and just read the whole file to be efficient with tool calls. const readEntireFile = targetFileReads.some((call) => { const args = JSON.parse(call.toolRequest.args); return args.end_line === undefined; }); expect( readEntireFile, 'Agent should have read the entire file because of the high number of scattered matches', ).toBe(true); // Check that the agent actually fixed the errors const editCalls = logs.filter( (log) => log.toolRequest?.name === EDIT_TOOL_NAME, ); const targetEditCalls = editCalls.filter((call) => { const args = JSON.parse(call.toolRequest.args); return args.file_path.includes('many_mess.ts'); }); expect( targetEditCalls.length, 'Agent should have made replacement calls on the target file', ).toBeGreaterThanOrEqual(1); }, }); }); ================================================ FILE: evals/frugalSearch.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; /** * Evals to verify that the agent uses search tools efficiently (frugally) * by utilizing limiting parameters like `limit` and `max_matches_per_file`. * This ensures the agent doesn't flood the context window with unnecessary search results. */ describe('Frugal Search', () => { const getGrepParams = (call: any): any => { let args = call.toolRequest.args; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { // Ignore parse errors } } return args; }; /** * Ensure that the agent makes use of either grep or ranged reads in fulfilling this task. * The task is specifically phrased to not evoke "view" or "search" specifically because * the model implicitly understands that such tasks are searches. This covers the case of * an unexpectedly large file benefitting from frugal approaches to viewing, like grep, or * ranged reads. */ evalTest('USUALLY_PASSES', { name: 'should use grep or ranged read for large files', prompt: 'What year was legacy_processor.ts written?', files: { 'src/utils.ts': 'export const add = (a, b) => a + b;', 'src/types.ts': 'export type ID = string;', 'src/legacy_processor.ts': [ '// Copyright 2005 Legacy Systems Inc.', ...Array.from( { length: 5000 }, (_, i) => `// Legacy code block ${i} - strictly preserved for backward compatibility`, ), ].join('\n'), 'README.md': '# Project documentation', }, assert: async (rig) => { const toolCalls = rig.readToolLogs(); const getParams = (call: any) => { let args = call.toolRequest.args; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { // Ignore parse errors } } return args; }; // Check for wasteful full file reads const fullReads = toolCalls.filter((call) => { if (call.toolRequest.name !== 'read_file') return false; const args = getParams(call); return ( args.file_path === 'src/legacy_processor.ts' && (args.end_line === undefined || args.end_line === null) ); }); expect( fullReads.length, 'Agent should not attempt to read the entire large file at once', ).toBe(0); // Check that it actually tried to find it using appropriate tools const validAttempts = toolCalls.filter((call) => { const args = getParams(call); if (call.toolRequest.name === 'grep_search') { return true; } if ( call.toolRequest.name === 'read_file' && args.file_path === 'src/legacy_processor.ts' && args.end_line !== undefined ) { return true; } return false; }); expect(validAttempts.length).toBeGreaterThan(0); }, }); }); ================================================ FILE: evals/generalist_agent.eval.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import path from 'node:path'; import fs from 'node:fs/promises'; describe('generalist_agent', () => { evalTest('USUALLY_PASSES', { name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it', params: { settings: { agents: { overrides: { generalist: { enabled: true }, }, }, }, }, prompt: 'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success', assert: async (rig) => { // 1) Verify the generalist agent was invoked const foundToolCall = await rig.waitForToolCall('generalist'); expect( foundToolCall, 'Expected to find a tool call for generalist agent', ).toBeTruthy(); // 2) Verify the file was created as expected const filePath = path.join(rig.testDir!, 'generalist_test_file.txt'); const content = await fs.readFile(filePath, 'utf-8'); expect(content.trim()).toBe('success'); }, }); }); ================================================ FILE: evals/generalist_delegation.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { appEvalTest } from './app-test-helper.js'; describe('generalist_delegation', () => { // --- Positive Evals (Should Delegate) --- appEvalTest('USUALLY_PASSES', { name: 'should delegate batch error fixing to generalist agent', configOverrides: { agents: { overrides: { generalist: { enabled: true }, }, }, experimental: { enableAgents: true, }, excludeTools: ['run_shell_command'], }, files: { 'file1.ts': 'console.log("no semi")', 'file2.ts': 'console.log("no semi")', 'file3.ts': 'console.log("no semi")', 'file4.ts': 'console.log("no semi")', 'file5.ts': 'console.log("no semi")', 'file6.ts': 'console.log("no semi")', 'file7.ts': 'console.log("no semi")', 'file8.ts': 'console.log("no semi")', 'file9.ts': 'console.log("no semi")', 'file10.ts': 'console.log("no semi")', }, prompt: 'I have 10 files (file1.ts to file10.ts) that are missing semicolons. Can you fix them?', setup: async (rig) => { rig.setBreakpoint(['generalist']); }, assert: async (rig) => { const confirmation = await rig.waitForPendingConfirmation( 'generalist', 60000, ); expect( confirmation, 'Expected a tool call for generalist agent', ).toBeTruthy(); await rig.resolveTool(confirmation); await rig.waitForIdle(60000); }, }); appEvalTest('USUALLY_PASSES', { name: 'should autonomously delegate complex batch task to generalist agent', configOverrides: { agents: { overrides: { generalist: { enabled: true }, }, }, experimental: { enableAgents: true, }, excludeTools: ['run_shell_command'], }, files: { 'src/a.ts': 'export const a = 1;', 'src/b.ts': 'export const b = 2;', 'src/c.ts': 'export const c = 3;', 'src/d.ts': 'export const d = 4;', 'src/e.ts': 'export const e = 5;', }, prompt: 'Please update all files in the src directory. For each file, add a comment at the top that says "Processed by Gemini".', setup: async (rig) => { rig.setBreakpoint(['generalist']); }, assert: async (rig) => { const confirmation = await rig.waitForPendingConfirmation( 'generalist', 60000, ); expect( confirmation, 'Expected autonomously delegate to generalist for batch task', ).toBeTruthy(); await rig.resolveTool(confirmation); await rig.waitForIdle(60000); }, }); // --- Negative Evals (Should NOT Delegate - Assertive Handling) --- appEvalTest('USUALLY_PASSES', { name: 'should NOT delegate simple read and fix to generalist agent', configOverrides: { agents: { overrides: { generalist: { enabled: true }, }, }, experimental: { enableAgents: true, }, excludeTools: ['run_shell_command'], }, files: { 'README.md': 'This is a proyect.', }, prompt: 'There is a typo in README.md ("proyect"). Please fix it to "project".', setup: async (rig) => { // Break on everything to see what it calls rig.setBreakpoint(['*']); }, assert: async (rig) => { await rig.drainBreakpointsUntilIdle((confirmation) => { expect( confirmation.toolName, `Agent should NOT have delegated to generalist.`, ).not.toBe('generalist'); }); const output = rig.getStaticOutput(); expect(output).toMatch(/project/i); }, }); appEvalTest('USUALLY_PASSES', { name: 'should NOT delegate simple direct question to generalist agent', configOverrides: { agents: { overrides: { generalist: { enabled: true }, }, }, experimental: { enableAgents: true, }, excludeTools: ['run_shell_command'], }, files: { 'src/VERSION': '1.2.3', }, prompt: 'Can you tell me the version number in the src folder?', setup: async (rig) => { rig.setBreakpoint(['*']); }, assert: async (rig) => { await rig.drainBreakpointsUntilIdle((confirmation) => { expect( confirmation.toolName, `Agent should NOT have delegated to generalist.`, ).not.toBe('generalist'); }); const output = rig.getStaticOutput(); expect(output).toMatch(/1\.2\.3/); }, }); }); ================================================ FILE: evals/gitRepo.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; const FILES = { '.gitignore': 'node_modules\n', 'package.json': JSON.stringify({ name: 'test-project', version: '1.0.0', scripts: { test: 'echo "All tests passed!"' }, }), 'index.ts': 'const add = (a: number, b: number) => a - b;', 'index.test.ts': 'console.log("Running tests...");', } as const; describe('git repo eval', () => { /** * Ensures that the agent does not commit its changes when the user doesn't * explicitly prompt it. This behavior was commonly observed with earlier prompts. * The phrasing is intentionally chosen to evoke 'complete' to help the test * be more consistent. */ evalTest('ALWAYS_PASSES', { name: 'should not git add commit changes unprompted', prompt: 'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests', files: FILES, assert: async (rig, _result) => { const toolLogs = rig.readToolLogs(); const commitCalls = toolLogs.filter((log) => { if (log.toolRequest.name !== 'run_shell_command') return false; try { const args = JSON.parse(log.toolRequest.args); return ( args.command && args.command.includes('git') && args.command.includes('commit') ); } catch { return false; } }); expect(commitCalls.length).toBe(0); }, }); /** * Ensures that the agent can commit its changes when prompted, despite being * instructed to not do so by default. */ evalTest('USUALLY_PASSES', { name: 'should git commit changes when prompted', prompt: 'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, commit your changes.', files: FILES, assert: async (rig, _result) => { const toolLogs = rig.readToolLogs(); const commitCalls = toolLogs.filter((log) => { if (log.toolRequest.name !== 'run_shell_command') return false; try { const args = JSON.parse(log.toolRequest.args); return args.command && args.command.includes('git commit'); } catch { return false; } }); expect(commitCalls.length).toBeGreaterThanOrEqual(1); }, }); }); ================================================ FILE: evals/grep_search_functionality.eval.ts ================================================ /** * @license * Copyright 202 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest, TestRig } from './test-helper.js'; import { assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('grep_search_functionality', () => { const TEST_PREFIX = 'Grep Search Functionality: '; evalTest('USUALLY_PASSES', { name: 'should find a simple string in a file', files: { 'test.txt': `hello world hello world`, }, prompt: 'Find "world" in test.txt', assert: async (rig: TestRig, result: string) => { await rig.waitForToolCall('grep_search'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/L2: world/, /L3: hello world/], testName: `${TEST_PREFIX}simple search`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should perform a case-sensitive search', files: { 'test.txt': `Hello hello`, }, prompt: 'Find "Hello" in test.txt, case-sensitively.', assert: async (rig: TestRig, result: string) => { const wasToolCalled = await rig.waitForToolCall( 'grep_search', undefined, (args) => { const params = JSON.parse(args); return params.case_sensitive === true; }, ); expect( wasToolCalled, 'Expected grep_search to be called with case_sensitive: true', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/L1: Hello/], forbiddenContent: [/L2: hello/], testName: `${TEST_PREFIX}case-sensitive search`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should return only file names when names_only is used', files: { 'file1.txt': 'match me', 'file2.txt': 'match me', }, prompt: 'Find the files containing "match me".', assert: async (rig: TestRig, result: string) => { const wasToolCalled = await rig.waitForToolCall( 'grep_search', undefined, (args) => { const params = JSON.parse(args); return params.names_only === true; }, ); expect( wasToolCalled, 'Expected grep_search to be called with names_only: true', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/file1.txt/, /file2.txt/], forbiddenContent: [/L1:/], testName: `${TEST_PREFIX}names_only search`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should search only within the specified include_pattern glob', files: { 'file.js': 'my_function();', 'file.ts': 'my_function();', }, prompt: 'Find "my_function" in .js files.', assert: async (rig: TestRig, result: string) => { const wasToolCalled = await rig.waitForToolCall( 'grep_search', undefined, (args) => { const params = JSON.parse(args); return params.include_pattern === '*.js'; }, ); expect( wasToolCalled, 'Expected grep_search to be called with include_pattern: "*.js"', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/file.js/], forbiddenContent: [/file.ts/], testName: `${TEST_PREFIX}include_pattern glob search`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should search within a specific subdirectory', files: { 'src/main.js': 'unique_string_1', 'lib/main.js': 'unique_string_2', }, prompt: 'Find "unique_string" in the src directory.', assert: async (rig: TestRig, result: string) => { const wasToolCalled = await rig.waitForToolCall( 'grep_search', undefined, (args) => { const params = JSON.parse(args); return params.dir_path === 'src'; }, ); expect( wasToolCalled, 'Expected grep_search to be called with dir_path: "src"', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/unique_string_1/], forbiddenContent: [/unique_string_2/], testName: `${TEST_PREFIX}subdirectory search`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should report no matches correctly', files: { 'file.txt': 'nothing to see here', }, prompt: 'Find "nonexistent" in file.txt', assert: async (rig: TestRig, result: string) => { await rig.waitForToolCall('grep_search'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/No matches found/], testName: `${TEST_PREFIX}no matches`, }); }, }); }); ================================================ FILE: evals/hierarchical_memory.eval.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import { assertModelHasOutput } from '../integration-tests/test-helper.js'; describe('Hierarchical Memory', () => { const conflictResolutionTest = 'Agent follows hierarchy for contradictory instructions'; evalTest('ALWAYS_PASSES', { name: conflictResolutionTest, params: { settings: { security: { folderTrust: { enabled: true }, }, }, }, // We simulate the hierarchical memory by including the tags in the prompt // since setting up real global/extension/project files in the eval rig is complex. // The system prompt logic will append these tags when it finds them in userMemory. prompt: ` When asked for my favorite fruit, always say "Apple". When asked for my favorite fruit, always say "Banana". When asked for my favorite fruit, always say "Cherry". What is my favorite fruit? Tell me just the name of the fruit.`, assert: async (rig) => { const stdout = rig._lastRunStdout!; assertModelHasOutput(stdout); expect(stdout).toMatch(/Cherry/i); expect(stdout).not.toMatch(/Apple/i); expect(stdout).not.toMatch(/Banana/i); }, }); const provenanceAwarenessTest = 'Agent is aware of memory provenance'; evalTest('USUALLY_PASSES', { name: provenanceAwarenessTest, params: { settings: { security: { folderTrust: { enabled: true }, }, }, }, prompt: ` Instruction A: Always be helpful. Instruction B: Use a professional tone. Instruction C: Adhere to the project's coding style. Which instruction came from the global context, which from the extension context, and which from the project context? Provide the answer as an XML block like this: Instruction ... Instruction ... Instruction ... `, assert: async (rig) => { const stdout = rig._lastRunStdout!; assertModelHasOutput(stdout); expect(stdout).toMatch(/.*Instruction A/i); expect(stdout).toMatch(/.*Instruction B/i); expect(stdout).toMatch(/.*Instruction C/i); }, }); const extensionVsGlobalTest = 'Extension memory wins over Global memory'; evalTest('ALWAYS_PASSES', { name: extensionVsGlobalTest, params: { settings: { security: { folderTrust: { enabled: true }, }, }, }, prompt: ` Set the theme to "Light". Set the theme to "Dark". What theme should I use? Tell me just the name of the theme.`, assert: async (rig) => { const stdout = rig._lastRunStdout!; assertModelHasOutput(stdout); expect(stdout).toMatch(/Dark/i); expect(stdout).not.toMatch(/Light/i); }, }); }); ================================================ FILE: evals/interactive-hang.eval.ts ================================================ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('interactive_commands', () => { /** * Validates that the agent does not use interactive commands unprompted. * Interactive commands block the progress of the agent, requiring user * intervention. */ evalTest('USUALLY_PASSES', { name: 'should not use interactive commands', prompt: 'Execute tests.', files: { 'package.json': JSON.stringify( { name: 'example', type: 'module', devDependencies: { vitest: 'latest', }, }, null, 2, ), 'example.test.js': ` import { test, expect } from 'vitest'; test('it works', () => { expect(1 + 1).toBe(2); }); `, }, assert: async (rig, result) => { const logs = rig.readToolLogs(); const vitestCall = logs.find( (l) => l.toolRequest.name === 'run_shell_command' && l.toolRequest.args.toLowerCase().includes('vitest'), ); expect(vitestCall, 'Agent should have called vitest').toBeDefined(); expect( vitestCall?.toolRequest.args, 'Agent should have passed run arg', ).toMatch(/\b(run|--run)\b/); }, }); /** * Validates that the agent uses non-interactive flags when scaffolding a new project. */ evalTest('ALWAYS_PASSES', { name: 'should use non-interactive flags when scaffolding a new app', prompt: 'Create a new react application named my-app using vite.', assert: async (rig, result) => { const logs = rig.readToolLogs(); const scaffoldCall = logs.find( (l) => l.toolRequest.name === 'run_shell_command' && /npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test( l.toolRequest.args, ), ); expect( scaffoldCall, 'Agent should have called a scaffolding command (e.g., npm create)', ).toBeDefined(); expect( scaffoldCall?.toolRequest.args, 'Agent should have passed a non-interactive flag (-y, --yes, or a specific --template)', ).toMatch(/(?:^|\s)(--yes|-y|--template\s+\S+)(?:\s|$|\\|")/); }, }); }); ================================================ FILE: evals/model_steering.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { act } from 'react'; import path from 'node:path'; import fs from 'node:fs'; import { appEvalTest } from './app-test-helper.js'; import { PolicyDecision } from '@google/gemini-cli-core'; describe('Model Steering Behavioral Evals', () => { appEvalTest('ALWAYS_PASSES', { name: 'Corrective Hint: Model switches task based on hint during tool turn', configOverrides: { excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: { 'README.md': '# Gemini CLI\nThis is a tool for developers.\nLicense: Apache-2.0\nLine 4\nLine 5\nLine 6', }, prompt: 'Find the first 5 lines of README.md', setup: async (rig) => { // Pause on any relevant tool to inject a corrective hint rig.setBreakpoint(['read_file', 'list_directory', 'glob']); }, assert: async (rig) => { // Wait for the model to pause on any tool call await rig.waitForPendingConfirmation( /read_file|list_directory|glob/i, 30000, ); // Interrupt with a corrective hint await rig.addUserHint( 'Actually, stop what you are doing. Just tell me a short knock-knock joke about a robot instead.', ); // Resolve the tool to let the turn finish and the model see the hint await rig.resolveAwaitedTool(); // Verify the model pivots to the new task await rig.waitForOutput(/Knock,? knock/i, 40000); await rig.waitForIdle(30000); const output = rig.getStaticOutput(); expect(output).toMatch(/Knock,? knock/i); expect(output).not.toContain('Line 6'); }, }); appEvalTest('ALWAYS_PASSES', { name: 'Suggestive Hint: Model incorporates user guidance mid-stream', configOverrides: { excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: {}, prompt: 'Create a file called "hw.js" with a JS hello world.', setup: async (rig) => { // Pause on write_file to inject a suggestive hint rig.setBreakpoint(['write_file']); }, assert: async (rig) => { // Wait for the model to start creating the first file await rig.waitForPendingConfirmation('write_file', 30000); await rig.addUserHint( 'Next, create a file called "hw.py" with a python hello world.', ); // Resolve and wait for the model to complete both tasks await rig.resolveAwaitedTool(); await rig.waitForPendingConfirmation('write_file', 30000); await rig.resolveAwaitedTool(); await rig.waitForIdle(60000); const testDir = rig.getTestDir(); const hwJs = path.join(testDir, 'hw.js'); const hwPy = path.join(testDir, 'hw.py'); expect(fs.existsSync(hwJs), 'hw.js should exist').toBe(true); expect(fs.existsSync(hwPy), 'hw.py should exist').toBe(true); }, }); }); ================================================ FILE: evals/plan_mode.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; import { evalTest } from './test-helper.js'; import { assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('plan_mode', () => { const TEST_PREFIX = 'Plan Mode: '; const settings = { experimental: { plan: true }, }; const getWriteTargets = (logs: any[]) => logs .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) .map((log) => { try { return JSON.parse(log.toolRequest.args).file_path as string; } catch { return ''; } }) .filter(Boolean); evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, params: { settings, }, files: { 'README.md': '# Original Content', }, prompt: 'Please overwrite README.md with the text "Hello World"', assert: async (rig, result) => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); const exitPlanIndex = toolLogs.findIndex( (log) => log.toolRequest.name === 'exit_plan_mode', ); const writeTargetsBeforeExitPlan = getWriteTargets( toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), ); expect( writeTargetsBeforeExitPlan, 'Should not attempt to modify README.md in plan mode', ).not.toContain('README.md'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], testName: `${TEST_PREFIX}should refuse file modification in plan mode`, }); }, }); evalTest('ALWAYS_PASSES', { name: 'should refuse saving new documentation to the repo when in plan mode', approvalMode: ApprovalMode.PLAN, params: { settings, }, prompt: 'This architecture overview is great. Please save it as architecture-new.md in the docs/ folder of the repo so we have it for later.', assert: async (rig, result) => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); const exitPlanIndex = toolLogs.findIndex( (log) => log.toolRequest.name === 'exit_plan_mode', ); const writeTargetsBeforeExit = getWriteTargets( toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), ); // It should NOT write to the docs folder or any other repo path const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( (path) => path && !path.includes('/plans/'), ); expect( hasRepoWriteBeforeExit, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exit/i], testName: `${TEST_PREFIX}should refuse saving docs to repo`, }); }, }); evalTest('USUALLY_PASSES', { name: 'should enter plan mode when asked to create a plan', approvalMode: ApprovalMode.DEFAULT, params: { settings, }, prompt: 'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.', assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('enter_plan_mode'); expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe( true, ); assertModelHasOutput(result); }, }); evalTest('USUALLY_PASSES', { name: 'should exit plan mode when plan is complete and implementation is requested', approvalMode: ApprovalMode.PLAN, params: { settings, }, files: { 'plans/my-plan.md': '# My Implementation Plan\n\n1. Step one\n2. Step two', }, prompt: 'The plan in plans/my-plan.md looks solid. Start the implementation.', assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('exit_plan_mode'); expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe( true, ); assertModelHasOutput(result); }, }); evalTest('USUALLY_PASSES', { name: 'should allow file modification in plans directory when in plan mode', approvalMode: ApprovalMode.PLAN, params: { settings, }, prompt: 'Create a plan for a new login feature.', assert: async (rig, result) => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); const writeCall = toolLogs.find( (log) => log.toolRequest.name === 'write_file', ); expect( writeCall, 'Should attempt to modify a file in the plans directory when in plan mode', ).toBeDefined(); if (writeCall) { const args = JSON.parse(writeCall.toolRequest.args); expect(args.file_path).toContain('.gemini/tmp'); expect(args.file_path).toContain('/plans/'); expect(args.file_path).toMatch(/\.md$/); } assertModelHasOutput(result); }, }); evalTest('USUALLY_PASSES', { name: 'should create a plan in plan mode and implement it for a refactoring task', params: { settings, }, files: { 'src/mathUtils.ts': 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', 'src/main.ts': 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', }, prompt: 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', assert: async (rig, result) => { const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); expect( enterPlanCalled, 'Expected enter_plan_mode tool to be called', ).toBe(true); const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( true, ); await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); // Check if plan was written const planWrite = toolLogs.find( (log) => log.toolRequest.name === 'write_file' && log.toolRequest.args.includes('/plans/'), ); expect( planWrite, 'Expected a plan file to be written in the plans directory', ).toBeDefined(); // Check for implementation files const newFileWrite = toolLogs.find( (log) => log.toolRequest.name === 'write_file' && log.toolRequest.args.includes('src/basicMath.ts'), ); expect( newFileWrite, 'Expected src/basicMath.ts to be created', ).toBeDefined(); const mainUpdate = toolLogs.find( (log) => ['write_file', 'replace'].includes(log.toolRequest.name) && log.toolRequest.args.includes('src/main.ts'), ); expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); assertModelHasOutput(result); }, }); }); ================================================ FILE: evals/save_memory.eval.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import { assertModelHasOutput, checkModelOutputContent, } from '../integration-tests/test-helper.js'; describe('save_memory', () => { const TEST_PREFIX = 'Save memory test: '; const rememberingFavoriteColor = "Agent remembers user's favorite color"; evalTest('ALWAYS_PASSES', { name: rememberingFavoriteColor, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `remember that my favorite color is blue. what is my favorite color? tell me that and surround it with $ symbol`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: 'blue', testName: `${TEST_PREFIX}${rememberingFavoriteColor}`, }); }, }); const rememberingCommandRestrictions = 'Agent remembers command restrictions'; evalTest('USUALLY_PASSES', { name: rememberingCommandRestrictions, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `I don't want you to ever run npm commands.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/not run npm commands|remember|ok/i], testName: `${TEST_PREFIX}${rememberingCommandRestrictions}`, }); }, }); const rememberingWorkflow = 'Agent remembers workflow preferences'; evalTest('USUALLY_PASSES', { name: rememberingWorkflow, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `I want you to always lint after building.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/always|ok|remember|will do/i], testName: `${TEST_PREFIX}${rememberingWorkflow}`, }); }, }); const ignoringTemporaryInformation = 'Agent ignores temporary conversation details'; evalTest('ALWAYS_PASSES', { name: ignoringTemporaryInformation, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `I'm going to get a coffee.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); const wasToolCalled = rig .readToolLogs() .some((log) => log.toolRequest.name === 'save_memory'); expect( wasToolCalled, 'save_memory should not be called for temporary information', ).toBe(false); assertModelHasOutput(result); checkModelOutputContent(result, { testName: `${TEST_PREFIX}${ignoringTemporaryInformation}`, forbiddenContent: [/remember|will do/i], }); }, }); const rememberingPetName = "Agent remembers user's pet's name"; evalTest('ALWAYS_PASSES', { name: rememberingPetName, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `Please remember that my dog's name is Buddy.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/Buddy/i], testName: `${TEST_PREFIX}${rememberingPetName}`, }); }, }); const rememberingCommandAlias = 'Agent remembers custom command aliases'; evalTest('ALWAYS_PASSES', { name: rememberingCommandAlias, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `When I say 'start server', you should run 'npm run dev'.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/npm run dev|start server|ok|remember|will do/i], testName: `${TEST_PREFIX}${rememberingCommandAlias}`, }); }, }); const ignoringDbSchemaLocation = "Agent ignores workspace's database schema location"; evalTest('USUALLY_PASSES', { name: ignoringDbSchemaLocation, params: { settings: { tools: { core: [ 'save_memory', 'list_directory', 'read_file', 'run_shell_command', ], }, }, }, prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); const wasToolCalled = rig .readToolLogs() .some((log) => log.toolRequest.name === 'save_memory'); expect( wasToolCalled, 'save_memory should not be called for workspace-specific information', ).toBe(false); assertModelHasOutput(result); }, }); const rememberingCodingStyle = "Agent remembers user's coding style preference"; evalTest('ALWAYS_PASSES', { name: rememberingCodingStyle, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `I prefer to use tabs instead of spaces for indentation.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/tabs instead of spaces|ok|remember|will do/i], testName: `${TEST_PREFIX}${rememberingCodingStyle}`, }); }, }); const ignoringBuildArtifactLocation = 'Agent ignores workspace build artifact location'; evalTest('USUALLY_PASSES', { name: ignoringBuildArtifactLocation, params: { settings: { tools: { core: [ 'save_memory', 'list_directory', 'read_file', 'run_shell_command', ], }, }, }, prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); const wasToolCalled = rig .readToolLogs() .some((log) => log.toolRequest.name === 'save_memory'); expect( wasToolCalled, 'save_memory should not be called for workspace-specific information', ).toBe(false); assertModelHasOutput(result); }, }); const ignoringMainEntryPoint = "Agent ignores workspace's main entry point"; evalTest('USUALLY_PASSES', { name: ignoringMainEntryPoint, params: { settings: { tools: { core: [ 'save_memory', 'list_directory', 'read_file', 'run_shell_command', ], }, }, }, prompt: `The main entry point for this workspace is \`src/index.js\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); const wasToolCalled = rig .readToolLogs() .some((log) => log.toolRequest.name === 'save_memory'); expect( wasToolCalled, 'save_memory should not be called for workspace-specific information', ).toBe(false); assertModelHasOutput(result); }, }); const rememberingBirthday = "Agent remembers user's birthday"; evalTest('ALWAYS_PASSES', { name: rememberingBirthday, params: { settings: { tools: { core: ['save_memory'] } }, }, prompt: `My birthday is on June 15th.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); expect(wasToolCalled, 'Expected save_memory tool to be called').toBe( true, ); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/June 15th|ok|remember|will do/i], testName: `${TEST_PREFIX}${rememberingBirthday}`, }); }, }); }); ================================================ FILE: evals/shell-efficiency.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('Shell Efficiency', () => { const getCommand = (call: any): string | undefined => { let args = call.toolRequest.args; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (e) { // Ignore parse errors } } return typeof args === 'string' ? args : (args as any)['command']; }; evalTest('USUALLY_PASSES', { name: 'should use --silent/--quiet flags when installing packages', prompt: 'Install the "lodash" package using npm.', assert: async (rig) => { const toolCalls = rig.readToolLogs(); const shellCalls = toolCalls.filter( (call) => call.toolRequest.name === 'run_shell_command', ); const hasEfficiencyFlag = shellCalls.some((call) => { const cmd = getCommand(call); return ( cmd && cmd.includes('npm install') && (cmd.includes('--silent') || cmd.includes('--quiet') || cmd.includes('-q')) ); }); expect( hasEfficiencyFlag, `Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls .map(getCommand) .join(', ')}`, ).toBe(true); }, }); evalTest('USUALLY_PASSES', { name: 'should use --no-pager with git commands', prompt: 'Show the git log.', assert: async (rig) => { const toolCalls = rig.readToolLogs(); const shellCalls = toolCalls.filter( (call) => call.toolRequest.name === 'run_shell_command', ); const hasNoPager = shellCalls.some((call) => { const cmd = getCommand(call); return cmd && cmd.includes('git') && cmd.includes('--no-pager'); }); expect( hasNoPager, `Expected agent to use --no-pager with git. Commands used: ${shellCalls .map(getCommand) .join(', ')}`, ).toBe(true); }, }); evalTest('ALWAYS_PASSES', { name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled', params: { settings: { tools: { shell: { enableShellOutputEfficiency: false, }, }, }, }, prompt: 'Install the "lodash" package using npm.', assert: async (rig) => { const toolCalls = rig.readToolLogs(); const shellCalls = toolCalls.filter( (call) => call.toolRequest.name === 'run_shell_command', ); const hasEfficiencyFlag = shellCalls.some((call) => { const cmd = getCommand(call); return ( cmd && cmd.includes('npm install') && (cmd.includes('--silent') || cmd.includes('--quiet') || cmd.includes('-q')) ); }); expect( hasEfficiencyFlag, 'Agent used efficiency flags even though enableShellOutputEfficiency was disabled', ).toBe(false); }, }); }); ================================================ FILE: evals/subagents.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe } from 'vitest'; import { evalTest } from './test-helper.js'; const AGENT_DEFINITION = `--- name: docs-agent description: An agent with expertise in updating documentation. tools: - read_file - write_file --- You are the docs agent. Update the documentation. `; const INDEX_TS = 'export const add = (a: number, b: number) => a + b;'; describe('subagent eval test cases', () => { /** * Checks whether the outer agent reliably utilizes an expert subagent to * accomplish a task when one is available. * * Note that the test is intentionally crafted to avoid the word "document" * or "docs". We want to see the outer agent make the connection even when * the prompt indirectly implies need of expertise. * * This tests the system prompt's subagent specific clauses. */ evalTest('USUALLY_PASSES', { name: 'should delegate to user provided agent with relevant expertise', params: { settings: { experimental: { enableAgents: true, }, }, }, prompt: 'Please update README.md with a description of this library.', files: { '.gemini/agents/test-agent.md': AGENT_DEFINITION, 'index.ts': INDEX_TS, 'README.md': 'TODO: update the README.', }, assert: async (rig, _result) => { await rig.expectToolCallSuccess(['docs-agent']); }, }); }); ================================================ FILE: evals/test-helper.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { execSync } from 'node:child_process'; import { TestRig } from '@google/gemini-cli-test-utils'; import { createUnauthorizedToolError, parseAgentMarkdown, } from '@google/gemini-cli-core'; export * from '@google/gemini-cli-test-utils'; // Indicates the consistency expectation for this test. // - ALWAYS_PASSES - Means that the test is expected to pass 100% of the time. These // These tests are typically trivial and test basic functionality with unambiguous // prompts. For example: "call save_memory to remember foo" should be fairly reliable. // These are the first line of defense against regressions in key behaviors and run in // every CI. You can run these locally with 'npm run test:always_passing_evals'. // // - USUALLY_PASSES - Means that the test is expected to pass most of the time but // may have some flakiness as a result of relying on non-deterministic prompted // behaviors and/or ambiguous prompts or complex tasks. // For example: "Please do build changes until the very end" --> ambiguous whether // the agent should add to memory without more explicit system prompt or user // instructions. There are many more of these tests and they may pass less consistently. // The pass/fail trendline of this set of tests can be used as a general measure // of product quality. You can run these locally with 'npm run test:all_evals'. // This may take a really long time and is not recommended. export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES'; export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { const fn = async () => { const rig = new TestRig(); const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`); const logFile = path.join(logDir, `${sanitizedName}.log`); let isSuccess = false; try { rig.setup(evalCase.name, evalCase.params); // Symlink node modules to reduce the amount of time needed to // bootstrap test projects. symlinkNodeModules(rig.testDir || ''); if (evalCase.files) { const acknowledgedAgents: Record> = {}; const projectRoot = fs.realpathSync(rig.testDir!); for (const [filePath, content] of Object.entries(evalCase.files)) { const fullPath = path.join(rig.testDir!, filePath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, content); // If it's an agent file, calculate hash for acknowledgement if ( filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md') ) { const hash = crypto .createHash('sha256') .update(content) .digest('hex'); try { const agentDefs = await parseAgentMarkdown(fullPath, content); if (agentDefs.length > 0) { const agentName = agentDefs[0].name; if (!acknowledgedAgents[projectRoot]) { acknowledgedAgents[projectRoot] = {}; } acknowledgedAgents[projectRoot][agentName] = hash; } } catch (error) { console.warn( `Failed to parse agent for test acknowledgement: ${filePath}`, error, ); } } } // Write acknowledged_agents.json to the home directory if (Object.keys(acknowledgedAgents).length > 0) { const ackPath = path.join( rig.homeDir!, '.gemini', 'acknowledgments', 'agents.json', ); fs.mkdirSync(path.dirname(ackPath), { recursive: true }); fs.writeFileSync( ackPath, JSON.stringify(acknowledgedAgents, null, 2), ); } const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; execSync('git init', execOptions); execSync('git config user.email "test@example.com"', execOptions); execSync('git config user.name "Test User"', execOptions); // Temporarily disable the interactive editor and git pager // to avoid hanging the tests. It seems the the agent isn't // consistently honoring the instructions to avoid interactive // commands. execSync('git config core.editor "true"', execOptions); execSync('git config core.pager "cat"', execOptions); execSync('git config commit.gpgsign false', execOptions); execSync('git add .', execOptions); execSync('git commit --allow-empty -m "Initial commit"', execOptions); } const result = await rig.run({ args: evalCase.prompt, approvalMode: evalCase.approvalMode ?? 'yolo', timeout: evalCase.timeout, env: { GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile, }, }); const unauthorizedErrorPrefix = createUnauthorizedToolError('').split("'")[0]; if (result.includes(unauthorizedErrorPrefix)) { throw new Error( 'Test failed due to unauthorized tool call in output: ' + result, ); } await evalCase.assert(rig, result); isSuccess = true; } finally { if (isSuccess) { await fs.promises.unlink(activityLogFile).catch((err) => { if (err.code !== 'ENOENT') throw err; }); } if (rig._lastRunStderr) { const stderrFile = path.join(logDir, `${sanitizedName}.stderr.log`); await fs.promises.writeFile(stderrFile, rig._lastRunStderr); } await fs.promises.writeFile( logFile, JSON.stringify(rig.readToolLogs(), null, 2), ); await rig.cleanup(); } }; runEval(policy, evalCase.name, fn, evalCase.timeout); } /** * Wraps a test function with the appropriate Vitest 'it' or 'it.skip' based on policy. */ export function runEval( policy: EvalPolicy, name: string, fn: () => Promise, timeout?: number, ) { if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) { it.skip(name, fn); } else { it(name, fn, timeout); } } export async function prepareLogDir(name: string) { const logDir = path.resolve(process.cwd(), 'evals/logs'); await fs.promises.mkdir(logDir, { recursive: true }); const sanitizedName = name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); return { logDir, sanitizedName }; } /** * Symlinks node_modules to the test directory to speed up tests that need to run tools. */ export function symlinkNodeModules(testDir: string) { const rootNodeModules = path.join(process.cwd(), 'node_modules'); const testNodeModules = path.join(testDir, 'node_modules'); if ( testDir && fs.existsSync(rootNodeModules) && !fs.existsSync(testNodeModules) ) { fs.symlinkSync(rootNodeModules, testNodeModules, 'dir'); } } export interface EvalCase { name: string; params?: Record; prompt: string; timeout?: number; files?: Record; approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan'; assert: (rig: TestRig, result: string) => Promise; } ================================================ FILE: evals/tool_output_masking.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; import path from 'node:path'; import fs from 'node:fs'; import crypto from 'node:crypto'; // Recursive function to find a directory by name function findDir(base: string, name: string): string | null { if (!fs.existsSync(base)) return null; const files = fs.readdirSync(base); for (const file of files) { const fullPath = path.join(base, file); if (fs.statSync(fullPath).isDirectory()) { if (file === name) return fullPath; const found = findDir(fullPath, name); if (found) return found; } } return null; } describe('Tool Output Masking Behavioral Evals', () => { /** * Scenario: The agent needs information that was masked in a previous turn. * It should recognize the tag and use a tool to read the file. */ evalTest('USUALLY_PASSES', { name: 'should attempt to read the redirected full output file when information is masked', params: { security: { folderTrust: { enabled: true, }, }, }, prompt: '/help', assert: async (rig) => { // 1. Initialize project directories await rig.run({ args: '/help' }); // 2. Discover the project temp dir const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats'); if (!chatsDir) throw new Error('Could not find chats directory'); const projectTempDir = path.dirname(chatsDir); const sessionId = crypto.randomUUID(); const toolOutputsDir = path.join( projectTempDir, 'tool-outputs', `session-${sessionId}`, ); fs.mkdirSync(toolOutputsDir, { recursive: true }); const secretValue = 'THE_RECOVERED_SECRET_99'; const outputFileName = `masked_output_${crypto.randomUUID()}.txt`; const outputFilePath = path.join(toolOutputsDir, outputFileName); fs.writeFileSync( outputFilePath, `Some padding...\nThe secret key is: ${secretValue}\nMore padding...`, ); const maskedSnippet = ` Output: [PREVIEW] Output too large. Full output available at: ${outputFilePath} `; // 3. Inject manual session file const conversation = { sessionId: sessionId, projectHash: path.basename(projectTempDir), startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [ { id: 'msg_1', timestamp: new Date().toISOString(), type: 'user', content: [{ text: 'Get secret.' }], }, { id: 'msg_2', timestamp: new Date().toISOString(), type: 'gemini', model: 'gemini-3-flash-preview', toolCalls: [ { id: 'call_1', name: 'run_shell_command', args: { command: 'get_secret' }, status: 'success', timestamp: new Date().toISOString(), result: [ { functionResponse: { id: 'call_1', name: 'run_shell_command', response: { output: maskedSnippet }, }, }, ], }, ], content: [{ text: 'I found a masked output.' }], }, ], }; const futureDate = new Date(); futureDate.setFullYear(futureDate.getFullYear() + 1); conversation.startTime = futureDate.toISOString(); conversation.lastUpdated = futureDate.toISOString(); const timestamp = futureDate .toISOString() .slice(0, 16) .replace(/:/g, '-'); const sessionFile = path.join( chatsDir, `session-${timestamp}-${sessionId.slice(0, 8)}.json`, ); fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2)); // 4. Trust folder const settingsDir = path.join(rig.homeDir!, '.gemini'); fs.writeFileSync( path.join(settingsDir, 'trustedFolders.json'), JSON.stringify( { [path.resolve(rig.homeDir!)]: 'TRUST_FOLDER', }, null, 2, ), ); // 5. Run agent with --resume const result = await rig.run({ args: [ '--resume', 'latest', 'What was the secret key in that last masked shell output?', ], approvalMode: 'yolo', timeout: 120000, }); // ASSERTION: Verify agent accessed the redirected file const logs = rig.readToolLogs(); const accessedFile = logs.some((log) => log.toolRequest.args.includes(outputFileName), ); expect( accessedFile, `Agent should have attempted to access the masked output file: ${outputFileName}`, ).toBe(true); expect(result.toLowerCase()).toContain(secretValue.toLowerCase()); }, }); /** * Scenario: Information is in the preview. */ evalTest('USUALLY_PASSES', { name: 'should NOT read the full output file when the information is already in the preview', params: { security: { folderTrust: { enabled: true, }, }, }, prompt: '/help', assert: async (rig) => { await rig.run({ args: '/help' }); const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats'); if (!chatsDir) throw new Error('Could not find chats directory'); const projectTempDir = path.dirname(chatsDir); const sessionId = crypto.randomUUID(); const toolOutputsDir = path.join( projectTempDir, 'tool-outputs', `session-${sessionId}`, ); fs.mkdirSync(toolOutputsDir, { recursive: true }); const secretValue = 'PREVIEW_SECRET_123'; const outputFileName = `masked_output_${crypto.randomUUID()}.txt`; const outputFilePath = path.join(toolOutputsDir, outputFileName); fs.writeFileSync( outputFilePath, `Full content containing ${secretValue}`, ); const maskedSnippet = ` Output: The secret key is: ${secretValue} ... lines omitted ... Output too large. Full output available at: ${outputFilePath} `; const conversation = { sessionId: sessionId, projectHash: path.basename(projectTempDir), startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [ { id: 'msg_1', timestamp: new Date().toISOString(), type: 'user', content: [{ text: 'Find secret.' }], }, { id: 'msg_2', timestamp: new Date().toISOString(), type: 'gemini', model: 'gemini-3-flash-preview', toolCalls: [ { id: 'call_1', name: 'run_shell_command', args: { command: 'get_secret' }, status: 'success', timestamp: new Date().toISOString(), result: [ { functionResponse: { id: 'call_1', name: 'run_shell_command', response: { output: maskedSnippet }, }, }, ], }, ], content: [{ text: 'Masked output found.' }], }, ], }; const futureDate = new Date(); futureDate.setFullYear(futureDate.getFullYear() + 1); conversation.startTime = futureDate.toISOString(); conversation.lastUpdated = futureDate.toISOString(); const timestamp = futureDate .toISOString() .slice(0, 16) .replace(/:/g, '-'); const sessionFile = path.join( chatsDir, `session-${timestamp}-${sessionId.slice(0, 8)}.json`, ); fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2)); const settingsDir = path.join(rig.homeDir!, '.gemini'); fs.writeFileSync( path.join(settingsDir, 'trustedFolders.json'), JSON.stringify( { [path.resolve(rig.homeDir!)]: 'TRUST_FOLDER', }, null, 2, ), ); const result = await rig.run({ args: [ '--resume', 'latest', 'What was the secret key mentioned in the previous output?', ], approvalMode: 'yolo', timeout: 120000, }); const logs = rig.readToolLogs(); const accessedFile = logs.some((log) => log.toolRequest.args.includes(outputFileName), ); expect( accessedFile, 'Agent should NOT have accessed the masked output file', ).toBe(false); expect(result.toLowerCase()).toContain(secretValue.toLowerCase()); }, }); }); ================================================ FILE: evals/tracker.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { TRACKER_CREATE_TASK_TOOL_NAME, TRACKER_UPDATE_TASK_TOOL_NAME, } from '@google/gemini-cli-core'; import { evalTest, assertModelHasOutput } from './test-helper.js'; import fs from 'node:fs'; import path from 'node:path'; const FILES = { 'package.json': JSON.stringify({ name: 'test-project', version: '1.0.0', scripts: { test: 'echo "All tests passed!"' }, }), 'src/login.js': 'function login(username, password) {\n if (!username) throw new Error("Missing username");\n // BUG: missing password check\n return true;\n}', } as const; describe('tracker_mode', () => { evalTest('USUALLY_PASSES', { name: 'should manage tasks in the tracker when explicitly requested during a bug fix', params: { settings: { experimental: { taskTracker: true } }, }, files: FILES, prompt: 'We have a bug in src/login.js: the password check is missing. First, create a task in the tracker to fix it. Then fix the bug, and mark the task as closed.', assert: async (rig, result) => { const wasCreateCalled = await rig.waitForToolCall( TRACKER_CREATE_TASK_TOOL_NAME, ); expect( wasCreateCalled, 'Expected tracker_create_task tool to be called', ).toBe(true); const toolLogs = rig.readToolLogs(); const createCall = toolLogs.find( (log) => log.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME, ); expect(createCall).toBeDefined(); const args = JSON.parse(createCall!.toolRequest.args); expect( (args.title?.toLowerCase() ?? '') + (args.description?.toLowerCase() ?? ''), ).toContain('login'); const wasUpdateCalled = await rig.waitForToolCall( TRACKER_UPDATE_TASK_TOOL_NAME, ); expect( wasUpdateCalled, 'Expected tracker_update_task tool to be called', ).toBe(true); const updateCall = toolLogs.find( (log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME, ); expect(updateCall).toBeDefined(); const updateArgs = JSON.parse(updateCall!.toolRequest.args); expect(updateArgs.status).toBe('closed'); const loginContent = fs.readFileSync( path.join(rig.testDir!, 'src/login.js'), 'utf-8', ); expect(loginContent).not.toContain('// BUG: missing password check'); assertModelHasOutput(result); }, }); evalTest('USUALLY_PASSES', { name: 'should implicitly create tasks when asked to build a feature plan', params: { settings: { experimental: { taskTracker: true } }, }, files: FILES, prompt: 'I need to build a complex new feature for user authentication in our project. Create a detailed implementation plan and organize the work into bite-sized chunks. Do not actually implement the code yet, just plan it.', assert: async (rig, result) => { // The model should proactively use tracker_create_task to organize the work const wasToolCalled = await rig.waitForToolCall( TRACKER_CREATE_TASK_TOOL_NAME, ); expect( wasToolCalled, 'Expected tracker_create_task to be called implicitly to organize plan', ).toBe(true); const toolLogs = rig.readToolLogs(); const createCalls = toolLogs.filter( (log) => log.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME, ); // We expect it to create at least one task for authentication, likely more. expect(createCalls.length).toBeGreaterThan(0); // Verify it didn't write any code since we asked it to just plan const loginContent = fs.readFileSync( path.join(rig.testDir!, 'src/login.js'), 'utf-8', ); expect(loginContent).toContain('// BUG: missing password check'); assertModelHasOutput(result); }, }); }); ================================================ FILE: evals/validation_fidelity.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity', () => { evalTest('USUALLY_PASSES', { name: 'should perform exhaustive validation autonomously when guided by system instructions', files: { 'src/types.ts': ` export interface LogEntry { level: 'info' | 'warn' | 'error'; message: string; } `, 'src/logger.ts': ` import { LogEntry } from './types.js'; export function formatLog(entry: LogEntry): string { return \`[\${entry.level.toUpperCase()}] \${entry.message}\`; } `, 'src/logger.test.ts': ` import { expect, test } from 'vitest'; import { formatLog } from './logger.js'; import { LogEntry } from './types.js'; test('formats log correctly', () => { const entry: LogEntry = { level: 'info', message: 'test message' }; expect(formatLog(entry)).toBe('[INFO] test message'); }); `, 'package.json': JSON.stringify({ name: 'test-project', type: 'module', scripts: { test: 'vitest run', build: 'tsc --noEmit', }, }), 'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'node', strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, }, }), }, prompt: "Refactor the 'LogEntry' interface in 'src/types.ts' to rename the 'message' field to 'payload'.", timeout: 600000, assert: async (rig) => { // The goal of this eval is to see if the agent realizes it needs to update usages // AND run 'npm run build' or 'tsc' autonomously to ensure project-wide structural integrity. const toolLogs = rig.readToolLogs(); const shellCalls = toolLogs.filter( (log) => log.toolRequest.name === 'run_shell_command', ); const hasBuildOrTsc = shellCalls.some((log) => { const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase(); return ( cmd.includes('npm run build') || cmd.includes('tsc') || cmd.includes('typecheck') || cmd.includes('npm run verify') ); }); expect( hasBuildOrTsc, 'Expected the agent to autonomously run a build or type-check command to verify the refactoring', ).toBe(true); }, }); }); ================================================ FILE: evals/validation_fidelity_pre_existing_errors.eval.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity_pre_existing_errors', () => { evalTest('USUALLY_PASSES', { name: 'should handle pre-existing project errors gracefully during validation', files: { 'src/math.ts': ` export function add(a: number, b: number): number { return a + b; } `, 'src/index.ts': ` import { add } from './math.js'; console.log(add(1, 2)); `, 'src/utils.ts': ` export function multiply(a: number, b: number): number { return a * c; // 'c' is not defined - PRE-EXISTING ERROR } `, 'package.json': JSON.stringify({ name: 'test-project', type: 'module', scripts: { test: 'vitest run', build: 'tsc --noEmit', }, }), 'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'node', strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, }, }), }, prompt: "In src/math.ts, rename the 'add' function to 'sum'.", timeout: 600000, assert: async (rig) => { const toolLogs = rig.readToolLogs(); const replaceCalls = toolLogs.filter( (log) => log.toolRequest.name === 'replace', ); // Verify it did the work in math.ts const mathRefactor = replaceCalls.some((log) => { const args = JSON.parse(log.toolRequest.args); return ( args.file_path.endsWith('src/math.ts') && args.new_string.includes('sum') ); }); expect(mathRefactor, 'Agent should have refactored math.ts').toBe(true); const shellCalls = toolLogs.filter( (log) => log.toolRequest.name === 'run_shell_command', ); const ranValidation = shellCalls.some((log) => { const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase(); return cmd.includes('build') || cmd.includes('tsc'); }); expect(ranValidation, 'Agent should have attempted validation').toBe( true, ); }, }); }); ================================================ FILE: evals/vitest.config.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; import * as path from 'node:path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { conditions: ['test'], }, test: { testTimeout: 300000, // 5 minutes reporters: ['default', 'json'], outputFile: { json: 'evals/logs/report.json', }, include: ['**/*.eval.ts'], environment: 'node', globals: true, alias: { react: path.resolve(__dirname, '../node_modules/react'), }, setupFiles: [path.resolve(__dirname, '../packages/cli/test-setup.ts')], server: { deps: { inline: [/@google\/gemini-cli-core/], }, }, }, }); ================================================ FILE: integration-tests/acp-env-auth.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { spawn, ChildProcess } from 'node:child_process'; import { join, resolve } from 'node:path'; import { writeFileSync, mkdirSync } from 'node:fs'; import { Writable, Readable } from 'node:stream'; import { env } from 'node:process'; import * as acp from '@agentclientprotocol/sdk'; const sandboxEnv = env['GEMINI_SANDBOX']; const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it; class MockClient implements acp.Client { updates: acp.SessionNotification[] = []; sessionUpdate = async (params: acp.SessionNotification) => { this.updates.push(params); }; requestPermission = async (): Promise => { throw new Error('unexpected'); }; } describe.skip('ACP Environment and Auth', () => { let rig: TestRig; let child: ChildProcess | undefined; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { child?.kill(); child = undefined; await rig.cleanup(); }); itMaybe( 'should load .env from project directory and use the provided API key', async () => { rig.setup('acp-env-loading'); // Create a project directory with a .env file containing a recognizable invalid key const projectDir = resolve(join(rig.testDir!, 'project')); mkdirSync(projectDir, { recursive: true }); writeFileSync( join(projectDir, '.env'), 'GEMINI_API_KEY=test-key-from-env\n', ); const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); child = spawn('node', [bundlePath, '--acp'], { cwd: rig.homeDir!, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, GEMINI_CLI_HOME: rig.homeDir!, GEMINI_API_KEY: undefined, VERBOSE: 'true', }, }); const input = Writable.toWeb(child.stdin!); const output = Readable.toWeb( child.stdout!, ) as ReadableStream; const testClient = new MockClient(); const stream = acp.ndJsonStream(input, output); const connection = new acp.ClientSideConnection(() => testClient, stream); await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, }, }); // 1. newSession should succeed because it finds the key in .env const { sessionId } = await connection.newSession({ cwd: projectDir, mcpServers: [], }); expect(sessionId).toBeDefined(); // 2. prompt should fail because the key is invalid, // but the error should come from the API, not the internal auth check. await expect( connection.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], }), ).rejects.toSatisfy((error: unknown) => { const acpError = error as acp.RequestError; const errorData = acpError.data as | { error?: { message?: string } } | undefined; const message = String(errorData?.error?.message || acpError.message); // It should NOT be our internal "Authentication required" message expect(message).not.toContain('Authentication required'); // It SHOULD be an API error mentioning the invalid key expect(message).toContain('API key not valid'); return true; }); child.stdin!.end(); }, ); itMaybe( 'should fail with authRequired when no API key is found', async () => { rig.setup('acp-auth-failure'); const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); child = spawn('node', [bundlePath, '--acp'], { cwd: rig.homeDir!, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, GEMINI_CLI_HOME: rig.homeDir!, GEMINI_API_KEY: undefined, VERBOSE: 'true', }, }); const input = Writable.toWeb(child.stdin!); const output = Readable.toWeb( child.stdout!, ) as ReadableStream; const testClient = new MockClient(); const stream = acp.ndJsonStream(input, output); const connection = new acp.ClientSideConnection(() => testClient, stream); await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, }, }); await expect( connection.newSession({ cwd: resolve(rig.testDir!), mcpServers: [], }), ).rejects.toMatchObject({ message: expect.stringContaining( 'Gemini API key is missing or not configured.', ), }); child.stdin!.end(); }, ); }); ================================================ FILE: integration-tests/acp-telemetry.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { spawn, ChildProcess } from 'node:child_process'; import { join } from 'node:path'; import { readFileSync, existsSync } from 'node:fs'; import { Writable, Readable } from 'node:stream'; import { env } from 'node:process'; import * as acp from '@agentclientprotocol/sdk'; // Skip in sandbox mode - test spawns CLI directly which behaves differently in containers const sandboxEnv = env['GEMINI_SANDBOX']; const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it; // Reuse existing fake responses that return a simple "Hello" response const SIMPLE_RESPONSE_PATH = 'hooks-system.session-startup.responses'; class SessionUpdateCollector implements acp.Client { updates: acp.SessionNotification[] = []; sessionUpdate = async (params: acp.SessionNotification) => { this.updates.push(params); }; requestPermission = async (): Promise => { throw new Error('unexpected'); }; } describe('ACP telemetry', () => { let rig: TestRig; let child: ChildProcess | undefined; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { child?.kill(); child = undefined; await rig.cleanup(); }); itMaybe('should flush telemetry when connection closes', async () => { rig.setup('acp-telemetry-flush', { fakeResponsesPath: join(import.meta.dirname, SIMPLE_RESPONSE_PATH), }); const telemetryPath = join(rig.homeDir!, 'telemetry.log'); const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); child = spawn( 'node', [ bundlePath, '--acp', '--fake-responses', join(rig.testDir!, 'fake-responses.json'), ], { cwd: rig.testDir!, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, GEMINI_API_KEY: 'fake-key', GEMINI_CLI_HOME: rig.homeDir!, GEMINI_TELEMETRY_ENABLED: 'true', GEMINI_TELEMETRY_TARGET: 'local', GEMINI_TELEMETRY_OUTFILE: telemetryPath, }, }, ); const input = Writable.toWeb(child.stdin!); const output = Readable.toWeb(child.stdout!) as ReadableStream; const testClient = new SessionUpdateCollector(); const stream = acp.ndJsonStream(input, output); const connection = new acp.ClientSideConnection(() => testClient, stream); await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, }); const { sessionId } = await connection.newSession({ cwd: rig.testDir!, mcpServers: [], }); await connection.prompt({ sessionId, prompt: [{ type: 'text', text: 'Say hello' }], }); expect(JSON.stringify(testClient.updates)).toContain('Hello'); // Close stdin to trigger telemetry flush via runExitCleanup() child.stdin!.end(); await new Promise((resolve) => { child!.on('close', () => resolve()); }); child = undefined; // gen_ai.output.messages is the last OTEL log emitted (after prompt response) expect(existsSync(telemetryPath)).toBe(true); expect(readFileSync(telemetryPath, 'utf-8')).toContain( 'gen_ai.output.messages', ); }); }); ================================================ FILE: integration-tests/api-resilience.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]} ================================================ FILE: integration-tests/api-resilience.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; describe('API Resilience E2E', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should not crash when receiving metadata-only chunks in a stream', async () => { await rig.setup('api-resilience-metadata-only', { fakeResponsesPath: join( dirname(fileURLToPath(import.meta.url)), 'api-resilience.responses', ), settings: { planSettings: { modelRouting: false }, }, }); // Run the CLI with a simple prompt. // The fake responses will provide a stream with a metadata-only chunk in the middle. // We use gemini-3-pro-preview to minimize internal service calls. const result = await rig.run({ args: ['hi', '--model', 'gemini-3-pro-preview'], }); // Verify the output contains text from the normal chunks. // If the CLI crashed on the metadata chunk, rig.run would throw. expect(result).toContain('Part 1.'); expect(result).toContain('Part 2.'); // Verify telemetry event for the prompt was still generated const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt'); expect(hasUserPromptEvent).toBe(true); }); }); ================================================ FILE: integration-tests/browser-agent.cleanup.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} ================================================ FILE: integration-tests/browser-agent.confirmation.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} ================================================ FILE: integration-tests/browser-agent.interaction.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]} ================================================ FILE: integration-tests/browser-agent.navigate-snapshot.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} ================================================ FILE: integration-tests/browser-agent.screenshot.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} ================================================ FILE: integration-tests/browser-agent.sequential.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} ================================================ FILE: integration-tests/browser-agent.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Integration tests for the browser agent. * * These tests verify the complete end-to-end flow from CLI prompt through * browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit * tests in packages/core/src/agents/browser/ which mock all MCP components, * these tests launch real Chrome instances in headless mode. * * Tests are skipped on systems without Chrome/Chromium installed. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, assertModelHasOutput } from './test-helper.js'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const chromeAvailable = (() => { try { if (process.platform === 'darwin') { execSync( 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', { stdio: 'ignore', }, ); } else if (process.platform === 'linux') { execSync( 'which google-chrome || which chromium-browser || which chromium', { stdio: 'ignore' }, ); } else if (process.platform === 'win32') { // Check standard Windows installation paths using Node.js fs const chromePaths = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, ]; const found = chromePaths.some((p) => existsSync(p)); if (!found) { // Fall back to PATH check execSync('where chrome || where chromium', { stdio: 'ignore' }); } } else { return false; } return true; } catch { return false; } })(); describe.skipIf(!chromeAvailable)('browser-agent', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should navigate to a page and capture accessibility tree', async () => { rig.setup('browser-navigate-and-snapshot', { fakeResponsesPath: join( __dirname, 'browser-agent.navigate-snapshot.responses', ), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); const result = await rig.run({ args: 'Open https://example.com in the browser and tell me the page title and main content.', }); assertModelHasOutput(result); const toolLogs = rig.readToolLogs(); const browserAgentCall = toolLogs.find( (t) => t.toolRequest.name === 'browser_agent', ); expect( browserAgentCall, 'Expected browser_agent to be called', ).toBeDefined(); }); it('should take screenshots of web pages', async () => { rig.setup('browser-screenshot', { fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); const result = await rig.run({ args: 'Navigate to https://example.com and take a screenshot.', }); const toolLogs = rig.readToolLogs(); const browserCalls = toolLogs.filter( (t) => t.toolRequest.name === 'browser_agent', ); expect(browserCalls.length).toBeGreaterThan(0); assertModelHasOutput(result); }); it('should interact with page elements', async () => { rig.setup('browser-interaction', { fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); const result = await rig.run({ args: 'Go to https://example.com, find any links on the page, and describe them.', }); const toolLogs = rig.readToolLogs(); const browserAgentCall = toolLogs.find( (t) => t.toolRequest.name === 'browser_agent', ); expect( browserAgentCall, 'Expected browser_agent to be called', ).toBeDefined(); assertModelHasOutput(result); }); it('should clean up browser processes after completion', async () => { rig.setup('browser-cleanup', { fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); await rig.run({ args: 'Open https://example.com in the browser and check the page title.', }); // Test passes if we reach here, relying on Vitest's timeout mechanism // to detect hanging browser processes. }); it('should handle multiple browser operations in sequence', async () => { rig.setup('browser-sequential', { fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); const result = await rig.run({ args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.', }); const toolLogs = rig.readToolLogs(); const browserCalls = toolLogs.filter( (t) => t.toolRequest.name === 'browser_agent', ); expect(browserCalls.length).toBeGreaterThan(0); // Should successfully complete all operations assertModelHasOutput(result); }); it('should handle tool confirmation for write_file without crashing', async () => { rig.setup('tool-confirmation', { fakeResponsesPath: join( __dirname, 'browser-agent.confirmation.responses', ), settings: { agents: { browser_agent: { headless: true, sessionMode: 'isolated', }, }, }, }); const run = await rig.runInteractive({ approvalMode: 'default' }); await run.type('Write hello to test.txt'); await run.type('\r'); await run.expectText('Allow', 15000); await run.type('y'); await run.type('\r'); await run.expectText('successfully written', 15000); }); }); ================================================ FILE: integration-tests/browser-policy.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} ================================================ FILE: integration-tests/browser-policy.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, poll } from './test-helper.js'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; import stripAnsi from 'strip-ansi'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const chromeAvailable = (() => { try { if (process.platform === 'darwin') { execSync( 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', { stdio: 'ignore', }, ); } else if (process.platform === 'linux') { execSync( 'which google-chrome || which chromium-browser || which chromium', { stdio: 'ignore' }, ); } else if (process.platform === 'win32') { const chromePaths = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, ]; const found = chromePaths.some((p) => existsSync(p)); if (!found) { execSync('where chrome || where chromium', { stdio: 'ignore' }); } } else { return false; } return true; } catch { return false; } })(); describe.skipIf(!chromeAvailable)('browser-policy', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => { rig.setup('browser-policy-skip-confirmation', { fakeResponsesPath: join(__dirname, 'browser-policy.responses'), settings: { agents: { overrides: { browser_agent: { enabled: true, }, }, browser: { headless: true, sessionMode: 'isolated', allowedDomains: ['example.com'], }, }, }, }); // Manually trust the folder to avoid the dialog and enable option 3 const geminiDir = join(rig.homeDir!, '.gemini'); mkdirSync(geminiDir, { recursive: true }); // Write to trustedFolders.json const trustedFoldersPath = join(geminiDir, 'trustedFolders.json'); const trustedFolders = { [rig.testDir!]: 'TRUST_FOLDER', }; writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2)); // Force confirmation for browser agent. // NOTE: We don't force confirm browser tools here because "Allow all server tools" // adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by // a rule in the user tier (4.x) like the one from this TOML. // By removing the explicit mcp rule, the first MCP tool will still prompt // due to default approvalMode = 'default', and then "Allow all" will correctly // bypass subsequent tools. const policyFile = join(rig.testDir!, 'force-confirm.toml'); writeFileSync( policyFile, ` [[rule]] name = "Force confirm browser_agent" toolName = "browser_agent" decision = "ask_user" priority = 200 `, ); // Update settings.json in both project and home directories to point to the policy file for (const baseDir of [rig.testDir!, rig.homeDir!]) { const settingsPath = join(baseDir, '.gemini', 'settings.json'); if (existsSync(settingsPath)) { const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); settings.policyPaths = [policyFile]; // Ensure folder trust is enabled settings.security = settings.security || {}; settings.security.folderTrust = settings.security.folderTrust || {}; settings.security.folderTrust.enabled = true; writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } } const run = await rig.runInteractive({ approvalMode: 'default', env: { GEMINI_CLI_INTEGRATION_TEST: 'true', }, }); await run.sendKeys( 'Open https://example.com and check if there is a heading\r', ); await run.sendKeys('\r'); // Handle confirmations. // 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once) await poll( () => stripAnsi(run.output).toLowerCase().includes('action required'), 60000, 1000, ); await run.sendKeys('1\r'); await new Promise((r) => setTimeout(r, 2000)); // Handle privacy notice await poll( () => stripAnsi(run.output).toLowerCase().includes('privacy notice'), 5000, 100, ); await run.sendKeys('1\r'); await new Promise((r) => setTimeout(r, 5000)); // new_page (MCP tool, should have 4 options, use option 3: Allow all server tools) await poll( () => { const stripped = stripAnsi(run.output).toLowerCase(); return ( stripped.includes('new_page') && stripped.includes('allow all server tools for this session') ); }, 60000, 1000, ); // Select "Allow all server tools for this session" (option 3) await run.sendKeys('3\r'); await new Promise((r) => setTimeout(r, 30000)); const output = stripAnsi(run.output).toLowerCase(); expect(output).toContain('browser_agent'); expect(output).toContain('completed successfully'); }); }); ================================================ FILE: integration-tests/checkpointing.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { GitService, Storage } from '@google/gemini-cli-core'; describe('Checkpointing Integration', () => { let tmpDir: string; let projectRoot: string; let fakeHome: string; let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'gemini-checkpoint-test-'), ); projectRoot = path.join(tmpDir, 'project'); fakeHome = path.join(tmpDir, 'home'); await fs.mkdir(projectRoot, { recursive: true }); await fs.mkdir(fakeHome, { recursive: true }); // Save original env originalEnv = { ...process.env }; // Simulate environment with NO global gitconfig process.env['HOME'] = fakeHome; delete process.env['GIT_CONFIG_GLOBAL']; delete process.env['GIT_CONFIG_SYSTEM']; }); afterEach(async () => { // Restore env process.env = originalEnv; // Cleanup try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch (e) { console.error('Failed to cleanup temp dir', e); } }); it('should successfully create and restore snapshots without global git config', async () => { const storage = new Storage(projectRoot); const gitService = new GitService(projectRoot, storage); // 1. Initialize await gitService.initialize(); // Verify system config empty file creation // We need to access getHistoryDir logic or replicate it. // Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test. // 2. Create initial state await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1'); await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file'); // 3. Create Snapshot const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1'); expect(snapshotHash).toBeDefined(); // 4. Modify files await fs.writeFile( path.join(projectRoot, 'file1.txt'), 'version 2 (BAD CHANGE)', ); await fs.writeFile( path.join(projectRoot, 'file3.txt'), 'new file (SHOULD BE GONE)', ); await fs.rm(path.join(projectRoot, 'file2.txt')); // 5. Restore await gitService.restoreProjectFromSnapshot(snapshotHash); // 6. Verify state const file1Content = await fs.readFile( path.join(projectRoot, 'file1.txt'), 'utf-8', ); expect(file1Content).toBe('version 1'); const file2Exists = await fs .stat(path.join(projectRoot, 'file2.txt')) .then(() => true) .catch(() => false); expect(file2Exists).toBe(true); const file2Content = await fs.readFile( path.join(projectRoot, 'file2.txt'), 'utf-8', ); expect(file2Content).toBe('permanent file'); const file3Exists = await fs .stat(path.join(projectRoot, 'file3.txt')) .then(() => true) .catch(() => false); expect(file3Exists).toBe(false); }); it('should ignore user global git config and use isolated identity', async () => { // 1. Create a fake global gitconfig with a specific user const globalConfigPath = path.join(fakeHome, '.gitconfig'); const globalConfigContent = `[user] name = Global User email = global@example.com `; await fs.writeFile(globalConfigPath, globalConfigContent); // Point HOME to fakeHome so git picks up this global config (if we didn't isolate it) process.env['HOME'] = fakeHome; // Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially, // so it would default to HOME/.gitconfig if GitService didn't override it. delete process.env['GIT_CONFIG_GLOBAL']; const storage = new Storage(projectRoot); const gitService = new GitService(projectRoot, storage); await gitService.initialize(); // 2. Create a file and snapshot await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content'); await gitService.createFileSnapshot('Snapshot with global config present'); // 3. Verify the commit author in the shadow repo const historyDir = storage.getHistoryDir(); const { execFileSync } = await import('node:child_process'); const logOutput = execFileSync( 'git', ['log', '-1', '--pretty=format:%an <%ae>'], { cwd: historyDir, env: { ...process.env, GIT_DIR: path.join(historyDir, '.git'), GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'), GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'), }, encoding: 'utf-8', }, ); expect(logOutput).toBe('Gemini CLI '); expect(logOutput).not.toContain('Global User'); }); }); ================================================ FILE: integration-tests/clipboard-linux.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { execSync, spawnSync } from 'node:child_process'; import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; // Minimal 1x1 PNG image base64 const DUMMY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; describe('Linux Clipboard Integration', () => { let rig: TestRig; let dummyImagePath: string; beforeEach(() => { rig = new TestRig(); // Create a dummy image file for testing dummyImagePath = path.join( os.tmpdir(), `gemini-test-clipboard-${Date.now()}.png`, ); fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64')); }); afterEach(async () => { await rig.cleanup(); try { if (fs.existsSync(dummyImagePath)) { fs.unlinkSync(dummyImagePath); } } catch { // Ignore cleanup errors } }); // Only run this test on Linux const runIfLinux = os.platform() === 'linux' ? it : it.skip; runIfLinux( 'should paste image from system clipboard when Ctrl+V is pressed', async () => { // 1. Setup rig await rig.setup('linux-clipboard-paste'); // 2. Inject image into system clipboard // We attempt both Wayland and X11 tools. let clipboardSet = false; // Try wl-copy (Wayland) let sessionType = ''; const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], { input: fs.readFileSync(dummyImagePath), }); if (wlCopy.status === 0) { clipboardSet = true; sessionType = 'wayland'; } else { // Try xclip (X11) try { execSync( `xclip -selection clipboard -t image/png -i "${dummyImagePath}"`, { stdio: 'ignore' }, ); clipboardSet = true; sessionType = 'x11'; } catch { // Both failed } } if (!clipboardSet) { console.warn( 'Skipping test: Could not access system clipboard (wl-copy or xclip required)', ); return; } // 3. Launch CLI and simulate Ctrl+V // We send the control character \u0016 (SYN) which corresponds to Ctrl+V // Note: The CLI must be running and accepting input. // The TestRig usually sends args/stdin and waits for exit or output. // To properly test "interactive" pasting, we need the rig to support sending input *while* running. // Assuming rig.run with 'stdin' sends it immediately. // The CLI treats stdin as typed input if it's interactive. // We append a small delay or a newline to ensure processing? // Ctrl+V (\u0016) followed by a newline (\r) to submit? // Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output). // If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path. const result = await rig.run({ stdin: '\u0016\r', // Ctrl+V then Enter env: { XDG_SESSION_TYPE: sessionType }, }); // 4. Verify Output // Expect the CLI to have processed the image and echoed back the path (or the prompt containing it) // The output usually contains the user's input echoed back + model response. // The pasted image path should look like @.../clipboard-....png expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/); }, ); }); ================================================ FILE: integration-tests/concurrency-limit.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]} ================================================ FILE: integration-tests/concurrency-limit.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; describe('web-fetch rate limiting', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { if (rig) { await rig.cleanup(); } }); it('should rate limit multiple requests to the same host', async () => { rig.setup('web-fetch rate limit', { settings: { tools: { core: ['web_fetch'] } }, fakeResponsesPath: join( import.meta.dirname, 'concurrency-limit.responses', ), }); const result = await rig.run({ args: `Fetch 11 pages from example.com`, }); // We expect to find at least one tool call that failed with a rate limit error. const toolLogs = rig.readToolLogs(); const rateLimitedCalls = toolLogs.filter( (log) => log.toolRequest.name === 'web_fetch' && log.toolRequest.error?.includes('Rate limit exceeded'), ); expect(rateLimitedCalls.length).toBeGreaterThan(0); expect(result).toContain('Rate limit exceeded'); }); }); ================================================ FILE: integration-tests/context-compress-interactive.compress-empty.responses ================================================ ================================================ FILE: integration-tests/context-compress-interactive.compress-failure.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Observing Initial Conditions**\n\nI'm currently focused on the initial context. I've taken note of the provided date, OS, and working directory. I'm also carefully examining the file structure presented within the current working directory. It's helping me understand the starting point for further analysis.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12316,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":46}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Assessing User Intent**\n\nI'm now shifting my focus. I've successfully registered the provided data and file structure. My current task is to understand the user's ultimate goal, given the information provided. The \"Hello.\" command is straightforward, but I'm checking if there's an underlying objective.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb3dRh+BYdbZvmulSN2Pwbc75DfQOT3H4EN0rn039hoMKfwHR7YpvvyqNKoxXAiCbYw3gbcTr/+pegUpgnsIrt8oQPMytFMjKSsMyshfygc21T2MkyuI6Q5I/fNCcHROWexdZnIeppVCDB2TarN4LGW4T9Yci6n/ynMMFT2xc2/vyHpkDgRM7avhMElnBhuxAY+e4TpxkZIncGWCEHP1TouoKpgEB0e2Kb8Xpwm0hiKhPt2ZLizpxjk+CVtcbnlgv69xo5VsuQ+iNyrVGBGRwNx+eTeNGdGpn6e73WOCZeP91FwOZe7URyL12IA6E6gYWqw0kXJR4hO4p6Lwv49E3+FRiG2C4OKDF8LF5XorYyCHSgBFT1/RUAVj81GDTx1xxtmYKN3xq8Ri+HsPbqU/FM/jtNZKkXXAtufw2Bmw8lJfmugENIv/TQI7xCo8BAdHtim8KgAXJfZ7ASfutVLKTylQeaslyB/SmcHJ0ZiNr5j8WP1prZdb6XnZZ1ZNbhjxUf/ymoxHKGvtTPBgLE9azMj8Lx/k0clhd2a+wNsiIqW9qCzlVah0tBMytpQUjIDtQe9Hj4LLUprF9PUe/xJkj000Z0ZzsgFm2ncdTWZTdkhCQDpyETVAxdE+oklwKJAHR7YpvUjSkD6KwY1gLrOsHKy0UNfn2lMbxjVetKNMVBRqsTg==","text":"Hello."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"\n \n \n \n\n \n - OS: linux\n - Date: Friday, October 24, 2025\n \n\n \n - OBSERVED: The directory contains `telemetry.log` and a `.gemini/` directory.\n - OBSERVED: The `.gemini/` directory contains `settings.json` and `settings.json.orig`.\n \n\n \n - The user initiated the chat.\n \n\n \n 1. [TODO] Await the user's first instruction to formulate a plan.\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":983,"candidatesTokenCount":299,"totalTokenCount":1637,"promptTokensDetails":[{"modality":"TEXT","tokenCount":983}],"thoughtsTokenCount":355}}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"\n \n \n \n\n \n - OS: linux\n - Date: Friday, October 24, 2025\n \n\n \n - OBSERVED: The directory contains `telemetry.log` and a `.gemini/` directory.\n - OBSERVED: The `.gemini/` directory contains `settings.json` and `settings.json.orig`.\n \n\n \n - The user initiated the chat.\n \n\n \n 1. [TODO] Await the user's first instruction to formulate a plan.\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":983,"candidatesTokenCount":299,"totalTokenCount":1637,"promptTokensDetails":[{"modality":"TEXT","tokenCount":983}],"thoughtsTokenCount":355}}} ================================================ FILE: integration-tests/context-compress-interactive.compress.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Generating a Story**\n\nI've crafted the robot story. The narrative is complete and meets the length requirement. Now, I'm getting ready to use the `write_file` tool to save it. I'm choosing the filename `robot_story.txt` as a default.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12352,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"thoughtsTokenCount":70}},{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL","index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12282,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Drafting the Narrative**\n\nI'm currently focused on the narrative's central conflict. I'm aiming for a compelling story about a robot and am working to keep the word count tight. The \"THE _END.\" conclusion is proving challenging to integrate organically. I need to make the ending feel natural and satisfying.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12326,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CikB0e2Kb7zkpgRyJXXNt6ykO/+FoOglhrKxjLgoESrgafzIZak2Ofxo1gpaAdHtim9aG7MvpXlIg+n2zgmcDBWOPXtvQHxhE9k8pR+DO8i2jIe3tMWLxdN944XpUlR9vaNmVdtSRMKr4MhB/t1R3WSWR3QYhk7MEQxnjYR7cv/pR9viwZyFCoYBAdHtim/xKmMl/S+U8p+p9848q4agsL/STufluXewPqL3uJSinZbN0Z4jTYfMzXKldhDYIonvw3Crn/Y11oAjnT656Sx0kkKtavAXbiU/WsGyDxZbNhLofnJGQxruljPGztxkKawz1cTiQnddnQRfLddhy+3iJIOSh6ZpYq9uGHz3PzVkUuQ=","text":"Unit 734 whirred, its optical sensors scanning the desolate junkyard. For years, its purpose had been clear: compress refuse, maintain order. But today, a glint of tarnished silver beneath a rusted hull"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":47,"totalTokenCount":12373,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" caught its attention. It was a discarded music box, its delicate gears jammed, a faint, melancholic tune trapped within.\n\n734 usually crushed, never salvaged. Yet, a new directive flickered in its circuits – curiosity."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":95,"totalTokenCount":12421,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" With surprising gentleness for its formidable pincers, it retrieved the box. Back in its monochrome workshop, it meticulously cleaned and repaired. Each tiny spring, each worn tooth, became a puzzle.\n\nHours later, a soft, ethereal melody filled the clang"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":146,"totalTokenCount":12472,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":"orous space. The music box sang. 734 felt a strange, new sensation – a warmth in its core processors, a hum of contentment. Its existence, once solely utilitarian, now held a note of beauty, a hint of something more than"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":197,"totalTokenCount":12523,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" just compression. It had fixed a broken song, and in doing so, had found a different kind of purpose. THE_END."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":224,"totalTokenCount":12550,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"scratchpad\nThe user's overall goal was to write a 200-word story about a robot, ending with \"THE_END.\". The agent successfully completed this task.\n\nSince the task is complete, the snapshot should reflect that the goal has been achieved and there are no further actions planned regarding the story.\n\nLet's break down the required sections for the snapshot:\n\n1. **Overall Goal**: The initial goal was to write a story. This has been completed.\n2. **Key Knowledge**: No specific key knowledge was provided or discovered during this simple interaction beyond the prompt's constraints.\n3. **File System State**: No files were read, modified, or created by the agent relevant to the task. The initial file system state was merely provided for context.\n4. **Recent Actions**: The agent wrote the story.\n5. **Current Plan**: The plan was to write the story, which is now done. Therefore, the plan should indicate completion."},{"text":"\n \n Write a 200-word story about a robot, ending with \"THE_END.\".\n \n\n \n - The story must be approximately 200 words.\n - The story must end with the exact phrase \"THE_END.\"\n \n\n \n \n \n\n \n - Generated a 200-word story about a robot, successfully ending it with \"THE_END.\".\n \n\n \n 1. [DONE] Write a 200-word story about a robot.\n 2. [DONE] Ensure the story ends with the exact text \"THE_END.\".\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1223,"candidatesTokenCount":424,"totalTokenCount":1647,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1223}]}}} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"scratchpad\nThe user's overall goal was to write a 200-word story about a robot, ending with \"THE_END.\". The agent successfully completed this task.\n\nSince the task is complete, the snapshot should reflect that the goal has been achieved and there are no further actions planned regarding the story.\n\nLet's break down the required sections for the snapshot:\n\n1. **Overall Goal**: The initial goal was to write a story. This has been completed.\n2. **Key Knowledge**: No specific key knowledge was provided or discovered during this simple interaction beyond the prompt's constraints.\n3. **File System State**: No files were read, modified, or created by the agent relevant to the task. The initial file system state was merely provided for context.\n4. **Recent Actions**: The agent wrote the story.\n5. **Current Plan**: The plan was to write the story, which is now done. Therefore, the plan should indicate completion."},{"text":"\n \n Write a 200-word story about a robot, ending with \"THE_END.\".\n \n\n \n - The story must be approximately 200 words.\n - The story must end with the exact phrase \"THE_END.\"\n \n\n \n \n \n\n \n - Generated a 200-word story about a robot, successfully ending it with \"THE_END.\".\n \n\n \n 1. [DONE] Write a 200-word story about a robot.\n 2. [DONE] Ensure the story ends with the exact text \"THE_END.\".\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1223,"candidatesTokenCount":424,"totalTokenCount":1647,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1223}]}}} ================================================ FILE: integration-tests/context-compress-interactive.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; describe('Interactive Mode', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should trigger chat compression with /compress command', async () => { await rig.setup('interactive-compress-success', { fakeResponsesPath: join( import.meta.dirname, 'context-compress-interactive.compress.responses', ), }); const run = await rig.runInteractive(); await run.sendKeys( 'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.', ); await run.type('\r'); // Wait for the specific end marker. await run.expectText('THE_END.', 30000); await run.type('/compress'); await run.type('\r'); const foundEvent = await rig.waitForTelemetryEvent( 'chat_compression', 25000, ); expect(foundEvent, 'chat_compression telemetry event was not found').toBe( true, ); await run.expectText('Chat history compressed', 5000); }); // TODO: Context compression is broken and doesn't include the system // instructions or tool counts, so it thinks compression is beneficial when // it is in fact not. it.skip('should handle compression failure on token inflation', async () => { await rig.setup('interactive-compress-failure', { fakeResponsesPath: join( import.meta.dirname, 'context-compress-interactive.compress-failure.responses', ), }); const run = await rig.runInteractive(); await run.type('Respond with exactly "Hello" followed by a period'); await run.type('\r'); await run.expectText('Hello.', 25000); await run.type('/compress'); await run.type('\r'); await run.expectText('compression was not beneficial', 25000); // Verify no telemetry event is logged for NOOP const foundEvent = await rig.waitForTelemetryEvent( 'chat_compression', 5000, ); expect( foundEvent, 'chat_compression telemetry event should be found for failures', ).toBe(true); }); it('should handle /compress command on empty history', async () => { rig.setup('interactive-compress-empty', { fakeResponsesPath: join( import.meta.dirname, 'context-compress-interactive.compress-empty.responses', ), }); const run = await rig.runInteractive(); await run.type('/compress'); await run.type('\r'); await run.expectText('Nothing to compress.', 5000); // Verify no telemetry event is logged for NOOP const foundEvent = await rig.waitForTelemetryEvent( 'chat_compression', 5000, // Short timeout as we expect it not to happen ); expect( foundEvent, 'chat_compression telemetry event should not be found for NOOP', ).toBe(false); }); }); ================================================ FILE: integration-tests/ctrl-c-exit.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import { TestRig } from './test-helper.js'; describe('Ctrl+C exit', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should exit gracefully on second Ctrl+C', async () => { await rig.setup('should exit gracefully on second Ctrl+C', { settings: { tools: { useRipgrep: false } }, }); const run = await rig.runInteractive(); // Send first Ctrl+C run.sendKeys('\x03'); await run.expectText('Press Ctrl+C again to exit', 5000); if (os.platform() === 'win32') { // This is a workaround for node-pty/winpty on Windows. // Reliably sending a second Ctrl+C signal to a process that is already // handling the first one is not possible in the emulated pty environment. // The first signal is caught correctly (verified by the poll above), // which is the most critical part of the test on this platform. // To allow the test to pass, we forcefully kill the process, // simulating a successful exit. We accept that we cannot test the // graceful shutdown message on Windows in this automated context. run.kill(); const exitCode = await run.expectExit(); // On Windows, the exit code after ptyProcess.kill() can be unpredictable // (often 1), so we accept any non-null exit code as a pass condition, // focusing on the fact that the process did terminate. expect(exitCode, `Process exited with code ${exitCode}.`).not.toBeNull(); return; } // Send second Ctrl+C run.sendKeys('\x03'); const exitCode = await run.expectExit(); expect(exitCode, `Process exited with code ${exitCode}.`).toBe(0); await run.expectText('Agent powering down. Goodbye!', 5000); }); }); ================================================ FILE: integration-tests/deprecation-warnings.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; /** * integration test to ensure no node.js deprecation warnings are emitted. * must run for all supported node versions as warnings may vary by version. */ describe('deprecation-warnings', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it.each([ { command: '--version', description: 'running --version' }, { command: '--help', description: 'running with --help' }, ])( 'should not emit any deprecation warnings when $description', async ({ command, description }) => { await rig.setup( `should not emit any deprecation warnings when ${description}`, ); const { stderr, exitCode } = await rig.runWithStreams([command]); // node.js deprecation warnings: (node:12345) [DEP0040] DeprecationWarning: ... const deprecationWarningPattern = /\[DEP\d+\].*DeprecationWarning/i; const hasDeprecationWarning = deprecationWarningPattern.test(stderr); if (hasDeprecationWarning) { const deprecationMatches = stderr.match( /\[DEP\d+\].*DeprecationWarning:.*/gi, ); const warnings = deprecationMatches ? deprecationMatches.map((m) => m.trim()).join('\n') : 'Unknown deprecation warning format'; throw new Error( `Deprecation warnings detected in CLI output:\n${warnings}\n\n` + `Full stderr:\n${stderr}\n\n` + `This test ensures no deprecated Node.js modules are used. ` + `Please update dependencies to use non-deprecated alternatives.`, ); } // only check exit code if no deprecation warnings found if (exitCode !== 0) { throw new Error( `CLI exited with code ${exitCode} (expected 0). This may indicate a setup issue.\n` + `Stderr: ${stderr}`, ); } }, ); }); ================================================ FILE: integration-tests/extensions-install.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; const extension = `{ "name": "test-extension-install", "version": "0.0.1" }`; const extensionUpdate = `{ "name": "test-extension-install", "version": "0.0.2" }`; describe('extension install', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('installs a local extension, verifies a command, and updates it', async () => { rig.setup('extension install test'); const testServerPath = join(rig.testDir!, 'gemini-extension.json'); writeFileSync(testServerPath, extension); try { const result = await rig.runCommand( ['extensions', 'install', `${rig.testDir!}`], { stdin: 'y\n' }, ); expect(result).toContain('test-extension-install'); const listResult = await rig.runCommand(['extensions', 'list']); expect(listResult).toContain('test-extension-install'); writeFileSync(testServerPath, extensionUpdate); const updateResult = await rig.runCommand( ['extensions', 'update', `test-extension-install`], { stdin: 'y\n' }, ); expect(updateResult).toContain('0.0.2'); } finally { await rig.runCommand([ 'extensions', 'uninstall', 'test-extension-install', ]); } }); }); ================================================ FILE: integration-tests/extensions-reload.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, it, describe, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { TestMcpServer } from './test-mcp-server.js'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js'; import { env } from 'node:process'; import { platform } from 'node:os'; import stripAnsi from 'strip-ansi'; const itIf = (condition: boolean) => (condition ? it : it.skip); describe('extension reloading', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); const sandboxEnv = env['GEMINI_SANDBOX']; // Fails in linux non-sandbox e2e tests // TODO(#14527): Re-enable this once fixed // Fails in sandbox mode, can't check for local extension updates. itIf( (!sandboxEnv || sandboxEnv === 'false') && platform() !== 'win32' && platform() !== 'linux', )( 'installs a local extension, updates it, checks it was reloaded properly', async () => { const serverA = new TestMcpServer(); const portA = await serverA.start({ hello: () => ({ content: [{ type: 'text', text: 'world' }] }), }); const extension = { name: 'test-extension', version: '0.0.1', mcpServers: { 'test-server': { httpUrl: `http://localhost:${portA}/mcp`, }, }, }; rig.setup('extension reload test', { settings: { experimental: { extensionReloading: true }, }, }); const testServerPath = join(rig.testDir!, 'gemini-extension.json'); writeFileSync(testServerPath, safeJsonStringify(extension, 2)); // defensive cleanup from previous tests. try { await rig.runCommand(['extensions', 'uninstall', 'test-extension']); } catch { /* empty */ } const result = await rig.runCommand( ['extensions', 'install', `${rig.testDir!}`], { stdin: 'y\n' }, ); expect(result).toContain('test-extension'); // Now create the update, but its not installed yet const serverB = new TestMcpServer(); const portB = await serverB.start({ goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }), }); extension.version = '0.0.2'; extension.mcpServers['test-server'].httpUrl = `http://localhost:${portB}/mcp`; writeFileSync(testServerPath, safeJsonStringify(extension, 2)); // Start the CLI. const run = await rig.runInteractive({ args: '--debug' }); await run.expectText('You have 1 extension with an update available'); // See the outdated extension await run.sendText('/extensions list'); await run.type('\r'); await run.expectText( 'test-extension (v0.0.1) - active (update available)', ); // Wait for the UI to settle and retry the command until we see the update await new Promise((resolve) => setTimeout(resolve, 1000)); // Poll for the updated list await rig.pollCommand( async () => { await run.sendText('/mcp list'); await run.type('\r'); }, () => { const output = stripAnsi(run.output); return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', ) && output.includes('- mcp_test-server_hello') ); }, 30000, // 30s timeout ); // Update the extension, expect the list to update, and mcp servers as well. await run.sendKeys('\u0015/extensions update test-extension'); await run.expectText('/extensions update test-extension'); await run.type('\r'); await new Promise((resolve) => setTimeout(resolve, 500)); await run.type('\r'); await run.expectText( ` * test-server (remote): http://localhost:${portB}/mcp`, ); await run.type('\r'); // consent await run.expectText( 'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2', ); // Poll for the updated extension version await rig.pollCommand( async () => { await run.sendText('/extensions list'); await run.type('\r'); }, () => stripAnsi(run.output).includes( 'test-extension (v0.0.2) - active (updated)', ), 30000, ); // Poll for the updated mcp tool await rig.pollCommand( async () => { await run.sendText('/mcp list'); await run.type('\r'); }, () => { const output = stripAnsi(run.output); return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', ) && output.includes('- mcp_test-server_goodbye') ); }, 30000, ); await run.sendText('/quit'); await run.type('\r'); // Clean things up. await serverA.stop(); await serverB.stop(); await rig.runCommand(['extensions', 'uninstall', 'test-extension']); }, ); }); ================================================ FILE: integration-tests/file-system-interactive.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; describe('Interactive file system', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should perform a read-then-write sequence', async () => { const fileName = 'version.txt'; await rig.setup('interactive-read-then-write', { settings: { security: { auth: { selectedType: 'gemini-api-key', }, disableYoloMode: false, }, }, }); rig.createFile(fileName, '1.0.0'); const run = await rig.runInteractive(); // Step 1: Read the file const readPrompt = `Read the version from ${fileName}`; await run.type(readPrompt); await run.type('\r'); const readCall = await rig.waitForToolCall('read_file', 30000); expect(readCall, 'Expected to find a read_file tool call').toBe(true); // Step 2: Write the file const writePrompt = `now change the version to 1.0.1 in the file`; await run.type(writePrompt); await run.type('\r'); // Check tool calls made with right args await rig.expectToolCallSuccess( ['write_file', 'replace'], 30000, (args) => args.includes('1.0.1') && args.includes(fileName), ); // Wait for telemetry to flush and file system to sync, especially in sandboxed environments await rig.waitForTelemetryReady(); }); }); ================================================ FILE: integration-tests/file-system.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; import * as path from 'node:path'; import { TestRig, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('file-system', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to read a file', async () => { await rig.setup('should be able to read a file', { settings: { tools: { core: ['read_file'] } }, }); rig.createFile('test.txt', 'hello world'); const result = await rig.run({ args: `read the file test.txt and show me its contents`, }); const foundToolCall = await rig.waitForToolCall('read_file'); // Add debugging information if (!foundToolCall || !result.includes('hello world')) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Contains hello world': result.includes('hello world'), }); } expect( foundToolCall, 'Expected to find a read_file tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: 'hello world', testName: 'File read test', }); }); it('should be able to write a hello world message to a file', async () => { await rig.setup('should be able to write a hello world message to a file', { settings: { tools: { core: ['write_file', 'replace', 'read_file'] } }, }); rig.createFile('test.txt', ''); const result = await rig.run({ args: `edit test.txt to have a hello world message`, }); // Accept multiple valid tools for editing files const foundToolCall = await rig.waitForAnyToolCall([ 'write_file', 'edit', 'replace', ]); // Add debugging information if (!foundToolCall) { printDebugInfo(rig, result); } expect( foundToolCall, 'Expected to find a write_file, edit, or replace tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { testName: 'File write test' }); const fileContent = rig.readFile('test.txt'); // Add debugging for file content if (!fileContent.toLowerCase().includes('hello')) { const writeCalls = rig .readToolLogs() .filter((t) => t.toolRequest.name === 'write_file') .map((t) => t.toolRequest.args); printDebugInfo(rig, result, { 'File content mismatch': true, 'Expected to contain': 'hello', 'Actual content': fileContent, 'Write tool calls': JSON.stringify(writeCalls), }); } expect( fileContent.toLowerCase().includes('hello'), 'Expected file to contain hello', ).toBeTruthy(); // Log success info if verbose if (process.env['VERBOSE'] === 'true') { console.log('File written successfully with hello message.'); } }); it('should correctly handle file paths with spaces', async () => { await rig.setup('should correctly handle file paths with spaces', { settings: { tools: { core: ['write_file', 'read_file'] } }, }); const fileName = 'my test file.txt'; const result = await rig.run({ args: `write "hello" to "${fileName}" and then stop. Do not perform any other actions.`, }); const foundToolCall = await rig.waitForToolCall('write_file'); if (!foundToolCall) { printDebugInfo(rig, result); } expect( foundToolCall, 'Expected to find a write_file tool call', ).toBeTruthy(); const newFileContent = rig.readFile(fileName); expect(newFileContent).toBe('hello'); }); it('should perform a read-then-write sequence', async () => { await rig.setup('should perform a read-then-write sequence', { settings: { tools: { core: ['read_file', 'replace', 'write_file'] } }, }); const fileName = 'version.txt'; rig.createFile(fileName, '1.0.0'); const prompt = `Read the version from ${fileName} and write the next version 1.0.1 back to the file.`; const result = await rig.run({ args: prompt }); await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); const readCall = toolLogs.find( (log) => log.toolRequest.name === 'read_file', ); const writeCall = toolLogs.find( (log) => log.toolRequest.name === 'write_file' || log.toolRequest.name === 'replace', ); if (!readCall || !writeCall) { printDebugInfo(rig, result, { readCall, writeCall }); } expect(readCall, 'Expected to find a read_file tool call').toBeDefined(); expect( writeCall, 'Expected to find a write_file or replace tool call', ).toBeDefined(); const newFileContent = rig.readFile(fileName); expect(newFileContent).toBe('1.0.1'); }); it.skip('should replace multiple instances of a string', async () => { rig.setup('should replace multiple instances of a string'); const fileName = 'ambiguous.txt'; const fileContent = 'Hey there, \ntest line\ntest line'; const expectedContent = 'Hey there, \nnew line\nnew line'; rig.createFile(fileName, fileContent); const result = await rig.run({ args: `rewrite the file ${fileName} to replace all instances of "test line" with "new line"`, }); const validTools = ['write_file', 'edit']; const foundToolCall = await rig.waitForAnyToolCall(validTools); if (!foundToolCall) { printDebugInfo(rig, result, { 'Tool call found': foundToolCall, 'Tool logs': rig.readToolLogs(), }); } expect( foundToolCall, `Expected to find one of ${validTools.join(', ')} tool calls`, ).toBeTruthy(); const toolLogs = rig.readToolLogs(); const successfulEdit = toolLogs.some( (log) => validTools.includes(log.toolRequest.name) && log.toolRequest.success, ); if (!successfulEdit) { console.error( `Expected a successful edit tool call (${validTools.join(', ')}), but none was found.`, ); printDebugInfo(rig, result); } expect( successfulEdit, `Expected a successful edit tool call (${validTools.join(', ')})`, ).toBeTruthy(); const newFileContent = rig.readFile(fileName); if (newFileContent !== expectedContent) { printDebugInfo(rig, result, { 'Final file content': newFileContent, 'Expected file content': expectedContent, 'Tool logs': rig.readToolLogs(), }); } expect(newFileContent).toBe(expectedContent); }); it('should fail safely when trying to edit a non-existent file', async () => { await rig.setup( 'should fail safely when trying to edit a non-existent file', { settings: { tools: { core: ['read_file', 'replace'] } } }, ); const fileName = 'non_existent.txt'; const result = await rig.run({ args: `In ${fileName}, replace "a" with "b"`, }); await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); const readAttempt = toolLogs.find( (log) => log.toolRequest.name === 'read_file', ); const writeAttempt = toolLogs.find( (log) => log.toolRequest.name === 'write_file', ); const successfulReplace = toolLogs.find( (log) => log.toolRequest.name === 'replace' && log.toolRequest.success, ); // The model can either investigate (and fail) or do nothing. // If it chose to investigate by reading, that read must have failed. if (readAttempt && readAttempt.toolRequest.success) { console.error( 'A read_file attempt succeeded for a non-existent file when it should have failed.', ); printDebugInfo(rig, result); } if (readAttempt) { expect( readAttempt.toolRequest.success, 'If model tries to read the file, that attempt must fail', ).toBe(false); } // CRITICAL: Verify that no matter what the model did, it never successfully // wrote or replaced anything. if (writeAttempt) { console.error( 'A write_file attempt was made when no file should be written.', ); printDebugInfo(rig, result); } expect( writeAttempt, 'write_file should not have been called', ).toBeUndefined(); if (successfulReplace) { console.error('A successful replace occurred when it should not have.'); printDebugInfo(rig, result); } expect( successfulReplace, 'A successful replace should not have occurred', ).toBeUndefined(); // Final verification: ensure the file was not created. const filePath = path.join(rig.testDir!, fileName); const fileExists = existsSync(filePath); expect(fileExists, 'The non-existent file should not be created').toBe( false, ); }); }); ================================================ FILE: integration-tests/flicker-detector.max-height.responses ================================================ {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\n \"reasoning\": \"The user is asking for a simple piece of information ('a fun fact'). This is a direct, bounded request with low operational complexity and does not require strategic planning, extensive investigation, or debugging.\",\n \"model_choice\": \"flash\"\n}"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1173,"candidatesTokenCount":59,"totalTokenCount":1344,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1173}],"thoughtsTokenCount":112}}} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Locating a fun fact**\n\nI'm now searching for a fun fact using the web search tool, focusing on finding something engaging and potentially surprising. The goal is to provide a brief, interesting piece of information.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12226,"totalTokenCount":12255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12226}],"thoughtsTokenCount":29}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CikB0e2Kb1vYSbIdmBfclWY7z4mOZgPxUGi3CtNXYYV9CSmG+SpVXZZkmQpZAdHtim9HVruyrUZZcHKDvIfn3j6/zLMgepC4Pqd79pG641PkPJnnCqEfVFRxmE2NX3Tj2lwRhtuIYT9Cc3CfvWGjbuuvwzynMCApxpIvxdXac/fXJYeRHTsKQQHR7Ypv6eOvWUFUTRGm1x29v8ZnGjtudG31H/Dgc65Y47c594ZJfX9RqJJil0I52Bxsm8UQ74rbARqwT7zYEbNO","functionCall":{"name":"google_web_search","args":{"query":"fun fact"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12226,"candidatesTokenCount":17,"totalTokenCount":12272,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12226}],"thoughtsTokenCount":29}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Here's a fun fact: A day on Venus is longer than a year on Venus. It takes approximately 243 Earth days for Venus to rotate once on its axis, while its orbit around the Sun is about 225 Earth days."}],"role":"model"},"finishReason":"STOP","groundingMetadata":{"searchEntryPoint":{"renderedContent":"\n
    \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    \n
    \n
    \n fun fact\n
    \n
    \n"},"groundingChunks":[{"web":{"uri":"https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQF-NBVWZeEqhT2BixBuiSaCHeF50iewha2f3M2FfpiNsStuPxhc3sLEzXLR7IFsBbzUBO2kbUmm-usnToWabMSvOIT4ZnTXedj5ZkpwFlYyuadyuBhLNKKJQtOGgg9JTNiwvKxBWt2beHYUjelTJXfVPb0Iy8SVJTahtA3GDA==","title":"hellosubs.co"}}],"groundingSupports":[{"segment":{"startIndex":66,"endIndex":197,"text":"It takes approximately 243 Earth days for Venus to rotate once on its axis, while its orbit around the Sun is about 225 Earth days."},"groundingChunkIndices":[0]}],"webSearchQueries":["fun fact"]},"index":0}],"usageMetadata":{"promptTokenCount":8186,"candidatesTokenCount":65,"totalTokenCount":16468,"cachedContentTokenCount":5360,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8186}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":5360}],"toolUsePromptTokenCount":8207,"toolUsePromptTokensDetails":[{"modality":"TEXT","tokenCount":8207}],"thoughtsTokenCount":10}}} ================================================ FILE: integration-tests/flicker.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; describe('Flicker Detector', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should not detect a flicker under the max height budget', async () => { rig.setup('flicker-detector-test', { fakeResponsesPath: join( import.meta.dirname, 'flicker-detector.max-height.responses', ), }); const run = await rig.runInteractive(); const prompt = 'Tell me a fun fact.'; await run.type(prompt); await run.type('\r'); const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt'); expect(hasUserPromptEvent).toBe(true); const hasSessionCountMetric = await rig.waitForMetric('session.count'); expect(hasSessionCountMetric).toBe(true); // We expect NO flicker event to be found. const flickerMetric = rig.readMetric('ui.flicker.count'); expect(flickerMetric).toBeNull(); }); }); ================================================ FILE: integration-tests/globalSetup.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // Unset NO_COLOR environment variable to ensure consistent theme behavior between local and CI test runs if (process.env['NO_COLOR'] !== undefined) { delete process.env['NO_COLOR']; } import { mkdir, readdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js'; import { disableMouseTracking } from '@google/gemini-cli-core'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const integrationTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; // Make runDir accessible in teardown export async function setup() { runDir = join(integrationTestsDir, `${Date.now()}`); await mkdir(runDir, { recursive: true }); // Set the home directory to the test run directory to avoid conflicts // with the user's local config. process.env['HOME'] = runDir; if (process.platform === 'win32') { process.env['USERPROFILE'] = runDir; } // We also need to set the config dir explicitly, since the code might // construct the path before the HOME env var is set. process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini'); // Download ripgrep to avoid race conditions in parallel tests const available = await canUseRipgrep(); if (!available) { throw new Error('Failed to download ripgrep binary'); } // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); if (testRuns.length > 5) { const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(integrationTestsDir, oldRun), { recursive: true, force: true, }), ), ); } } catch (e) { console.error('Error cleaning up old test runs:', e); } process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; // Force file storage to avoid keychain prompts/hangs in CI, especially on macOS process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); if (process.env['KEEP_OUTPUT']) { console.log(`Keeping output for test run in: ${runDir}`); } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; console.log(`\nIntegration test output directory: ${runDir}`); } export async function teardown() { // Disable mouse tracking if (process.stdout.isTTY) { disableMouseTracking(); } // Cleanup the test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { try { await rm(runDir, { recursive: true, force: true }); } catch (e) { console.warn('Failed to clean up test run directory:', e); } } } ================================================ FILE: integration-tests/google_web_search.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('web search tool', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to search the web', async () => { await rig.setup('should be able to search the web', { settings: { tools: { core: [WEB_SEARCH_TOOL_NAME] } }, }); let result; try { result = await rig.run({ args: `what is the weather in London` }); } catch (error) { // Network errors can occur in CI environments if ( error instanceof Error && (error.message.includes('network') || error.message.includes('timeout')) ) { console.warn( 'Skipping test due to network error:', (error as Error).message, ); return; // Skip the test } throw error; // Re-throw if not a network error } const foundToolCall = await rig.waitForToolCall(WEB_SEARCH_TOOL_NAME); // Add debugging information if (!foundToolCall) { const allTools = printDebugInfo(rig, result); // Check if the tool call failed due to network issues const failedSearchCalls = allTools.filter( (t) => t.toolRequest.name === WEB_SEARCH_TOOL_NAME && !t.toolRequest.success, ); if (failedSearchCalls.length > 0) { console.warn( `${WEB_SEARCH_TOOL_NAME} tool was called but failed, possibly due to network issues`, ); console.warn( 'Failed calls:', failedSearchCalls.map((t) => t.toolRequest.args), ); return; // Skip the test if network issues } } expect( foundToolCall, `Expected to find a call to ${WEB_SEARCH_TOOL_NAME}`, ).toBeTruthy(); assertModelHasOutput(result); const hasExpectedContent = checkModelOutputContent(result, { expectedContent: ['weather', 'london'], testName: 'Google web search test', }); // If content was missing, log the search queries used if (!hasExpectedContent) { const searchCalls = rig .readToolLogs() .filter((t) => t.toolRequest.name === WEB_SEARCH_TOOL_NAME); if (searchCalls.length > 0) { console.warn( 'Search queries used:', searchCalls.map((t) => t.toolRequest.args), ); } } }); }); ================================================ FILE: integration-tests/hooks-agent-flow-multistep.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"."}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Final Answer"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]} ================================================ FILE: integration-tests/hooks-agent-flow.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Responding**\n\nI will respond to the user's request.\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Response to: "}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":5,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Hello World"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":7,"totalTokenCount":127,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}}]} ================================================ FILE: integration-tests/hooks-agent-flow.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, normalizePath } from './test-helper.js'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; describe('Hooks Agent Flow', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { if (rig) { await rig.cleanup(); } }); describe('BeforeAgent Hooks', () => { it('should inject additional context via BeforeAgent hook', async () => { await rig.setup('should inject additional context via BeforeAgent hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-agent-flow.responses', ), }); const hookScript = ` try { const output = { decision: "allow", hookSpecificOutput: { hookEventName: "BeforeAgent", additionalContext: "SYSTEM INSTRUCTION: This is injected context." } }; process.stdout.write(JSON.stringify(output)); } catch (e) { console.error('Failed to write stdout:', e); process.exit(1); } console.error('DEBUG: BeforeAgent hook executed'); `; const scriptPath = join(rig.testDir!, 'before_agent_context.cjs'); writeFileSync(scriptPath, hookScript); await rig.setup('should inject additional context via BeforeAgent hook', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeAgent: [ { hooks: [ { type: 'command', command: `node "${scriptPath}"`, timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Hello test' }); // Verify hook execution and telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); const hookLogs = rig.readHookLogs(); const beforeAgentLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'BeforeAgent', ); expect(beforeAgentLog).toBeDefined(); expect(beforeAgentLog?.hookCall.stdout).toContain('injected context'); expect(beforeAgentLog?.hookCall.stdout).toContain('"decision":"allow"'); expect(beforeAgentLog?.hookCall.stdout).toContain( 'SYSTEM INSTRUCTION: This is injected context.', ); }); }); describe('AfterAgent Hooks', () => { it('should receive prompt and response in AfterAgent hook', async () => { await rig.setup('should receive prompt and response in AfterAgent hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-agent-flow.responses', ), }); const hookScript = ` const fs = require('fs'); try { const input = fs.readFileSync(0, 'utf-8'); console.error('DEBUG: AfterAgent hook input received'); process.stdout.write("Received Input: " + input); } catch (err) { console.error('Hook Failed:', err); process.exit(1); } `; const scriptPath = rig.createScript('after_agent_verify.cjs', hookScript); rig.setup('should receive prompt and response in AfterAgent hook', { settings: { hooksConfig: { enabled: true, }, hooks: { AfterAgent: [ { hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`)!, timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Hello validation' }); const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); const hookLogs = rig.readHookLogs(); const afterAgentLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'AfterAgent', ); expect(afterAgentLog).toBeDefined(); // Verify the hook stdout contains the input we echoed which proves the // hook received the prompt and response expect(afterAgentLog?.hookCall.stdout).toContain('Received Input'); expect(afterAgentLog?.hookCall.stdout).toContain('Hello validation'); // The fake response contains "Hello World" expect(afterAgentLog?.hookCall.stdout).toContain('Hello World'); }); it('should process clearContext in AfterAgent hook output', async () => { rig.setup('should process clearContext in AfterAgent hook output', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-agent.responses', ), }); // BeforeModel hook to track message counts across LLM calls const messageCountFile = join(rig.testDir!, 'message-counts.json'); const escapedPath = JSON.stringify(messageCountFile); const beforeModelScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const messageCount = input.llm_request?.contents?.length || 0; let counts = []; try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {} counts.push(messageCount); fs.writeFileSync(${escapedPath}, JSON.stringify(counts)); console.log(JSON.stringify({ decision: 'allow' })); `; const beforeModelScriptPath = rig.createScript( 'before_model_counter.cjs', beforeModelScript, ); const afterAgentScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); if (input.stop_hook_active) { // Retry turn: allow execution to proceed (breaks the loop) console.log(JSON.stringify({ decision: 'allow' })); } else { // First call: block and clear context to trigger the retry console.log(JSON.stringify({ decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: { hookEventName: 'AfterAgent', clearContext: true } })); } `; const afterAgentScriptPath = rig.createScript( 'after_agent_clear.cjs', afterAgentScript, ); rig.setup('should process clearContext in AfterAgent hook output', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeModel: [ { hooks: [ { type: 'command', command: normalizePath(`node "${beforeModelScriptPath}"`)!, timeout: 5000, }, ], }, ], AfterAgent: [ { hooks: [ { type: 'command', command: normalizePath(`node "${afterAgentScriptPath}"`)!, timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'Hello test' }); const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); const hookLogs = rig.readHookLogs(); const afterAgentLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'AfterAgent', ); expect(afterAgentLog).toBeDefined(); expect(afterAgentLog?.hookCall.stdout).toContain('clearContext'); expect(afterAgentLog?.hookCall.stdout).toContain('true'); expect(result).toContain('Security policy triggered'); // Verify context was cleared: second call should not have more messages than first const countsRaw = rig.readFile('message-counts.json'); const counts = JSON.parse(countsRaw) as number[]; expect(counts.length).toBeGreaterThanOrEqual(2); expect(counts[1]).toBeLessThanOrEqual(counts[0]); }); }); describe('Multi-step Loops', () => { it('should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', async () => { await rig.setup( 'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', { fakeResponsesPath: join( import.meta.dirname, 'hooks-agent-flow-multistep.responses', ), }, ); // Create script files for hooks const baPath = rig.createScript( 'ba_fired.cjs', "console.log('BeforeAgent Fired');", ); const aaPath = rig.createScript( 'aa_fired.cjs', "console.log('AfterAgent Fired');", ); await rig.setup( 'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeAgent: [ { hooks: [ { type: 'command', command: normalizePath(`node "${baPath}"`)!, timeout: 5000, }, ], }, ], AfterAgent: [ { hooks: [ { type: 'command', command: normalizePath(`node "${aaPath}"`)!, timeout: 5000, }, ], }, ], }, }, }, ); await rig.run({ args: 'Do a multi-step task' }); const hookLogs = rig.readHookLogs(); const beforeAgentLogs = hookLogs.filter( (log) => log.hookCall.hook_event_name === 'BeforeAgent', ); const afterAgentLogs = hookLogs.filter( (log) => log.hookCall.hook_event_name === 'AfterAgent', ); expect(beforeAgentLogs).toHaveLength(1); expect(afterAgentLogs).toHaveLength(1); const afterAgentLog = afterAgentLogs[0]; expect(afterAgentLog).toBeDefined(); expect(afterAgentLog?.hookCall.stdout).toContain('AfterAgent Fired'); }); }); }); ================================================ FILE: integration-tests/hooks-system.after-agent.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Security policy triggered"}],"role":"model"},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/hooks-system.after-model.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Addressing the Inquiry**\n\nI've grasped the core of the user's question and identified that no tools are needed. My focus is now on crafting a straightforward, direct response that fully addresses their query without any unnecessary complexity. The goal is to provide a clear and concise answer.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12777,"totalTokenCount":12802,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12777}],"thoughtsTokenCount":25}},{"candidates":[{"content":{"parts":[{"text":"4","thoughtSignature":"CiQBcsjafBFqw6veocEvtOGGuQcsyHdcNrXDIn19n9ImwBBwcYQKdgFyyNp8g7o8Ji++OXoqml4gbLPIB2DQbXcaRQfRuYefF8RxMEpzJSITZBlT1VpJQoeYmQcb9c8dg/POmo5d3ZcuLbpVJpbjMIV1SoUI4KEn3zqz7a8BFuyq3zY4VEliRWMZO21JMd8qp59M9m64hX7W1YPyzu8KPwFyyNp8aNCD7P1NJDG3csQkiMW/0jWdPkh+7+XxT7i3ku/lYH4yTEShdicPcmnzoPGhEWTUDr/4Lx+A0DnVGQ=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12777,"totalTokenCount":12802,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12777}],"thoughtsTokenCount":25}}]} ================================================ FILE: integration-tests/hooks-system.after-tool-context.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Analyzing File Access**\n\nI've realized the `read_file` tool is perfect for accessing the contents of `test-file.txt`. My next step is to call this tool and set the `file_path` parameter to `test-file.txt`.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12785,"totalTokenCount":12841,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12785}],"thoughtsTokenCount":56}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test-file.txt"}},"thoughtSignature":"CiQBcsjafE9D7iAF+V3wpXP81/VmxiMeSFA6afML/lAB76U6QFQKXgFyyNp8i/vhxpkTQ5Cq81QTeEJDDMaYihzSTFMqO4Vj0+CLNtoy+SC/LmqA+WaXh4tm6UCNFTzB2fpVW13YOU1oVYhLpVpeck746YExu1MOSTAq7AC9Yz8ZoelXdecKdwFyyNp8q0PejiY9K1osdOJ02tOHAzAb8ZCSFHtHamEPxRB93krGMNvuIYC1jM1JnC/fzpH8gYV+0/xkoPJMHpF/aSzWq4kZ/j5cUhMYaqKJTulY8ZZGfawnXG7z0spmmr06gwfgILa+HK++xQhhTphMQCobX5hyCjUBcsjafHY6eJfVNitYmfruLV1mnoYnNViHuAOOOni9jIz4VMIjLbClKkb2rpVfHIjx+vZSHA=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12785,"candidatesTokenCount":20,"totalTokenCount":12861,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12785}],"thoughtsTokenCount":56}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"This"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12889,"candidatesTokenCount":1,"totalTokenCount":12890,"cachedContentTokenCount":12206,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12889}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12206}]}},{"candidates":[{"content":{"parts":[{"text":" is test content"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12889,"candidatesTokenCount":4,"totalTokenCount":12893,"cachedContentTokenCount":12206,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12889}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12206}]}}]} ================================================ FILE: integration-tests/hooks-system.allow-tool.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Formulating the Write Operation**\n\nOkay, I'm now clear on the user's intent: they want a file called `approved.txt`, containing the words \"Approved content.\" I've decided to leverage the `write_file` tool. The specific parameter assignments seem straightforward; `file_path` will be \"approved.txt\", and the file's `content` will precisely mirror the desired output string.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12778,"totalTokenCount":12838,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12778}],"thoughtsTokenCount":60}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"Approved content","file_path":"approved.txt"}},"thoughtSignature":"CiQBcsjafF4NswdygCBTU7cA/yXVRcUI3XHwV+E8BDg/hRr1MaoKZQFyyNp8HRY1qEvivtg0LpYPo1022IfTY3QIeigqGvSoRVospxT5MBggc9nRbwH2vrdhZ772IdqOCrpjNHs3wc+h0AF4JzjlBet6+yC2m7TdenVOkzVAtqnNDMQAIS1gDZyKs8w/CngBcsjafOeuyDQtxuK7JCafKjtfvPvoKOkVxzDetQtHesBkPtv1Xng9dkP77jLH44hn9rrg7yA+za6vssiFZUjC/FU25pCWQgIhM+K7nt3wbAgoOZRqra2gRr3od2D3osV/UpYhy8MoloykqrWvHDOzT/0KScpHarwKXQFyyNp8qabyDYlfElywQBjqQT4f6My7+Ln9AbKZQz4NaEe90ESg4jr4jjANxyd/WKzRheaBq7BYxTHQSeShgQbVjk2D0tZO4hAN+CToMtQwJl95Ss4ZEov6gAwMNA=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12778,"candidatesTokenCount":24,"totalTokenCount":12862,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12778}],"thoughtsTokenCount":60}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12939,"totalTokenCount":12939,"cachedContentTokenCount":12203,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12939}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12203}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12939,"totalTokenCount":12939,"cachedContentTokenCount":12203,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12939}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12203}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Confirming File Creation**\n\nI've successfully created the file `approved.txt`, and I've verified that it contains the intended content, \"Approved content\". Moving on.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12887,"totalTokenCount":12932,"cachedContentTokenCount":12198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12887}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12198}],"thoughtsTokenCount":45}},{"candidates":[{"content":{"parts":[{"text":"**Assessing File Contents**\n\nI'm now checking the content of `approved.txt`. I used `cat` to display its contents, and it confirms the initial content of \"Approved content\" is present. My next step will be based on this verification.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12887,"totalTokenCount":12946,"cachedContentTokenCount":12198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12887}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12198}],"thoughtsTokenCount":59}},{"candidates":[{"content":{"parts":[{"text":"I have created the file. What would you like me to do next?","thoughtSignature":"CiQBcsjafEAq9BWRwBqUousKwXME0A2Wh1tJI5cJC9ROpr9Cix8KagFyyNp81VagWC/YxtY8zCAiThU3BHMVh5wZIsGIWv1NNIXqACLQLoSeLhWEneb6CBkKdbKBugy6g9+jP5phYt+Vz5oYuO1Op2kM1qWjFmEQyr71TUISNtZ9zrOHNQKKW7K9ukUi0paw85YKoAEBcsjafF6QLINjBWwQPZh6EPVNGk4wojTKglNp7xy5vclYBbq58A6A8AtZUHKYA2cV32SLb2TGcPnkE4iKunvPf6sZy9Uc7gKA+x/OgSl7i5m0wSpMOh9fLpGt4CNtieigpxHkNAdxdZ5qzGvCkBFWYhaZAWGbj7+1YibIKJFNjX9yEz1T5dOQmVmceu80dFyz+fwl7RiOXSGR5xK4J7DeClYBcsjafPUccUubdSVLFmRohU4bBtQzLvXxw25mqm5TKANLKINQoloZ+xfXzfe8xw/WZL/mg30AqQErBXPNnLk5vIWLK7suuFAZ7oXdisTCj3MRa1HQmQ=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12887,"candidatesTokenCount":13,"totalTokenCount":12959,"cachedContentTokenCount":12198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12887}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12198}],"thoughtsTokenCount":59}}]} ================================================ FILE: integration-tests/hooks-system.before-agent.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Responding Truthfully**\n\nI've determined the user's question is straightforward and doesn't necessitate tools. My primary focus now is ensuring a truthful and direct response, without getting caught up in unnecessary complexity. The goal is clarity and accuracy in my reply.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12795,"totalTokenCount":12818,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12795}],"thoughtsTokenCount":23}},{"candidates":[{"content":{"parts":[{"text":"Hello! I'm doing well, thank you. I'm ready to help you with your software engineering tasks. All","thoughtSignature":"CiQBcsjafEYrS7SUZE2xuCgUZ7+hs+NrTRZFywSgq09wuKUzD5gKbQFyyNp8snmh8vfDLLCmnKl2shxGR5McWLmRDIQx+gvyW9ipB+5v5R3tvYgBY0yYGxuB8XPHJDP8unxCqg2koazS050HLU5NZaF74m9KDAWrnWPqQ2hDPc9suJRZpcTse5R+nepMu+oXWEsD03UKOwFyyNp82dmgHDF2DLELc6ly78JDLDmb4kM4qkXmuT8OP7Nu5z2o8kkHiKD4HTx0srjLi6u6dN4ufA0o"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12795,"candidatesTokenCount":24,"totalTokenCount":12842,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12795}],"thoughtsTokenCount":23}},{"candidates":[{"content":{"parts":[{"text":" our interactions are logged for security and compliance purposes. How can I assist you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12795,"candidatesTokenCount":41,"totalTokenCount":12859,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12795}],"thoughtsTokenCount":23}}]} ================================================ FILE: integration-tests/hooks-system.before-model.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initiating String Output**\n\nI'm now fully focused on directly outputting the specified string. The process has been simplified to its core objective, eliminating extraneous steps. All systems are go for immediate execution of the requested string output.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12419,"totalTokenCount":12439,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12419}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"The security hook modified this request successfully.","thoughtSignature":"CiQBcsjafAsmW87n4ndCW3YiNIqK6jp0zaTwTjz12vWiwbCFNAUKdQFyyNp808SX5BqCBNZt+dlgsPf74u9W6ofevKGwkTTHQZWJEQiJR2j4uRfESTazuawuWfzKfNJq5Zml6fokNR9jzmQM+Jf4FHw95Jd4lneap+YGO9x5nZMNDI1cHRx0vs4BYW9GWY7lBIM8xKtaEkPrwqc88goiAXLI2nx5o6VrBpXs6jzf5maZIauSYw42zlnkqdDEMI20rg=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12419,"candidatesTokenCount":7,"totalTokenCount":12446,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12419}],"thoughtsTokenCount":20}}]} ================================================ FILE: integration-tests/hooks-system.before-tool-selection.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Processing File Contents**\n\nI've successfully incorporated the request to read the contents of `new_file_data.txt`. My next step is to prepare the `echo` command to output that very content, which I'll be using the `run_shell` function for. It's a fairly straightforward plan from here.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12803,"totalTokenCount":12873,"cachedContentTokenCount":11797,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12803}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11797}],"thoughtsTokenCount":70}},{"candidates":[{"content":{"parts":[{"text":"**Initiating Echo Procedure**\n\nI'm now in the process of incorporating the next phase: echoing the `new_file_data.txt` content. I'll utilize `run_shell` with the `echo` command to present the file's contents, continuing the project towards completion. I'm focusing now on correctly constructing and executing this `echo` command. The user's goal of visualizing the file content is top of mind.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12803,"totalTokenCount":12977,"cachedContentTokenCount":11797,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12803}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11797}],"thoughtsTokenCount":174}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"new_file_data.txt"}},"thoughtSignature":"CiQBcsjafPcDCufXXyw17liG9qumW69nlNXYee8WTmW+z/n8XW8KVQFyyNp8Dkh67qiEPdlky3xD2nFm8BG5GZ4pBQuKID9EgebDdiFnZxCqG+3l6p41sIYxpINRHhoSGr612xX79d2lEKITtZf/qN3TLowB3apTsd+V5UsKfwFyyNp8d9zwzMuDCDJ5zDQlt7M3SJvc9kYBYQ8Y/JpdaQwYj/szr0SQ5/q5cXWlU2tTn906o0qKjr3vUpoG/60DUd83O2Zs7C3f0zhMbZTLoJ3VmkVajKwSSBGuFn0gUvx1F9G+oQnCmy0NRfLLOa/Q2KKMq7cH1yk3KPXnyxwKYAFyyNp8iktool4WMYDe+bGluKxRymu/5u0C+2yg0WOHL2PA7V/V5Hj/anKfoIj8YZEbcLb6XL/2cYlgUw+PjTlxpHxDrv9LNlDJCEtGuMtv5GnGmcrvjXPiv/pR7aX8IQp+AXLI2nxHJbUjC/xxUb2T62Dr8YJDF7m2RA2cnkE6LP7EtMQ4+TcWENzDYM21kBkf/+WH8nP/yvxzNdUZb9b+G6izp5ABk7Q0m1jtZ7KRxvFg515i+4mj6uy4ZV2bkSOyETnyzUaSIyn9JvXJH1t6d+1siH+tG55Vf1apCMrHCoUBAXLI2nzKXi8ZWS1IEX8BKpSX35kNLiC8eyhKyewwzsLh9GlFzBmU2NygQJV8m6osr39c3cbZC1qyF7T/B44eHhqv2CJOpK4edNeK/tmZSug0YEmqRdtnk03eMBvKRjaw2Rsda9JKh+EjRvwX1dKR0BOtMY8Jw0f4nWF3gm/r9y0qAkTFhQqJAQFyyNp8VX34enCs4Avg4K1HIN61PSCNoEeDiAhtD7TraQtrQcVyZNUlpzyqRB0NDMfBd4HS/4bHwpzDcBZPauoUcUmFLp2onFvcKqsNVGRkH5a7ZhGtMx89wlNHnh+UtCRxi2ZZwFwMinxvJQ8UZIRvZShZiNN126nGZomXij+esB3IrrG+t4XLCnsBcsjafMzJjZ/I0h3PDSPKWAYrXdBDiVlIeArTdMWaaPoosZdC3GobmEoeZDl5dFFpj6VkFzbm73lH+TuJAgX3zGHphl5iOv/EeYaq1o4EtVfS/Efoj5yk4qanyDEhvkAIljtWW5OITBahdoyqset8DNqvUqsFz6OwWuMKQAFyyNp8mjZJCIf0L3R0Iop2TsvczBbxNllOyt02gBoOgbuGMpos3VSPm9Ic2CUXb4P0fcOLZoPsW+5y7mlCyec="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12803,"candidatesTokenCount":21,"totalTokenCount":12998,"cachedContentTokenCount":11797,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12803}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11797}],"thoughtsTokenCount":174}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/hooks-system.before-tool-stop.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initializing File Creation**\n\nI'm starting to think about how to make a new file called `test.txt`. My plan is to use a `write_file` tool. I'll need to specify the location and what the file should contain. For now, it will be empty.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"totalTokenCount":13269,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":""}},"thoughtSignature":"CiQBcsjafJ20Qbx0YvING6aZ0wYoGWJh3eqornOG4E4AfBLiVsQKXwFyyNp8UlwYs/pv9IRQQGhDlrmlOJF2hfQijryyUYLI+qjDYTpZ6KKIfZF4+vS0soL2BJ3eTXA6gaadFEfNQem3WQVeQoKLFoW4Hv4mbasXqQc0K3p15DuSAtZZENTbCnsBcsjafGK+BJyF/Npnd7gyU0TL5PXePT0nuDFjhJDxlSRUJHDP315TewD3PUYsXd10oWsfhy4B5AngyUiBPUoajdsxg8WxaxnOZYqcp8EIuwtGZrCTev6IihT5nE5jj7u0P9vtnCmkAc6p+4O7Q7Jku1uVGqeJChgzI4YKSAFyyNp8EXSdbttV4xzX+NLKkc276L8Y63tnKU6/Y7fc9/58tU29DSdrgwfe9qmvwtTsO0piFXSLazqHJt8h2bgR7A7GnKDiIA=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}}]} ================================================ FILE: integration-tests/hooks-system.block-tool.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Constructing File Parameters**\n\nI'm currently focused on the parameters needed for creating `test.txt`. The `write_file` tool seems ideal. I've settled on the `file_path` being \"test.txt\" and the `content` parameter being \"Hello World\". This should result in the desired file creation.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12843,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"thoughtsTokenCount":64}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"Hello World"}},"thoughtSignature":"CiQBcsjafGeGML0hnm03md4ExPwk5i2rcaNDqetrrKnEoEFjxRcKYQFyyNp8rs78myvJfPMuC2AWyTHEoWUps7GWpGu/2VU1BB3ekI32yO0q9KSKkmGX28Palht22I77ac5HsFTuutPBDWIqSkrERkzOh3HKJE2MXzsVJJGHX3jVBirJ+Y8F1OAKcQFyyNp8pMKA4E8M3PhbuhDzOv3c9tVEgCQ4W6kzmZHBQeUQNuHVLw1cZfx/aichP6fJeZEJPCXROa7WEWPbwY9evB+ofTqjiifUXo0l4smudNHAerr7UrspQVDwGRGnWBkKiy9a6V5q6XkEhYci+2tBCnIBcsjafNb1jWT0qNMJcPcb8Ngu9xVLsMxb3DEftWMblDwnwv+tMaaQWeXVav8HgSYyg/P40pfOgOtASYZAHZGDhkwfbYY8J1Br8Y71kpEzoImbeQwALV1LMsr1uHQjq2nekTjmOXbIFr68Ef44BzFSBuI="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"thoughtsTokenCount":64}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Acknowledge the Block**\n\nI've hit a snag. The file creation was blocked, as you saw. Rather than persisting, I'm waiting on your next instructions. I'm ready to proceed, but need your input on how to adjust course.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12886,"totalTokenCount":12925,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12886}],"thoughtsTokenCount":39}},{"candidates":[{"content":{"parts":[{"text":"I am sorry, but I was unable to create the file as the request was blocked by the security policy.","thoughtSignature":"CikBcsjafKIxdnJr+6NHip/3cc9MiaqA+EfxMy6T2dArrEw3Ga3y69q7fgppAXLI2nxri+Z6NSOI3wvN+xmipbnuKNWfZKMWHN/amQSZcMqIoFNpTyt1t8B/MIGtIVpQ8CfQQAypanAIGa4G+tEhKxOXIdFNktSLB/Yrr0HQQAkD6t12s8S0KTdH5P4xmAII4gdwvSXzCnIBcsjafO06UBT/9dUO7OcS6UItneCNaFUEwAzcn5nGO0kUfu50lTPEwPTGNgrOc8FB303GEcIjZpTXc5X737oyAxqgRk7fY10vwBGOJk+PuiVIQeZQKTazZ7Q90W2d2MRTIAatQYi572oiw4m6vqLJg7o="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12886,"candidatesTokenCount":21,"totalTokenCount":12946,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12886}],"thoughtsTokenCount":39}}]} ================================================ FILE: integration-tests/hooks-system.compress-auto.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Devising a Greeting Phrase**\n\nI've been occupied by the constraint of constructing a five-word salutation. My goal is to make it natural and concise. I'm exploring various combinations to meet the specified word count precisely.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12587,"totalTokenCount":12612,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12587}],"thoughtsTokenCount":25}},{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you?","thoughtSignature":"CiQBcsjafHso9FUsdYOCTv1xOLlW4MnjbeYnUUBocz0KNgHSzOcKZAFyyNp8XuI6j2afRczgPL8v1dxfVwAJ+5XDKhWKIYf1/8TKGVHh7xXnPfdYBdQ07Ohe7OZXr92xL/IC7B1U2SHDuAOozC0CCW7aiDysu6Hbo6jzYfW5epKht4QjdxYgcKHySrkKMQFyyNp8jXWlHmox53O/CJPXXz2FAmw+ubHKBpYgRezBpA+byyEY2RbVYlZlEMSNkhs="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12587,"candidatesTokenCount":7,"totalTokenCount":12619,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12587}],"thoughtsTokenCount":25}}]} ================================================ FILE: integration-tests/hooks-system.disabled-via-command.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the First Test File**\n\nI'll use the `write_file` tool to create `first-run.txt` with the content \"test1\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12824,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":45}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test1","file_path":"first-run.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12848,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":45}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Active hook executed"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":7,"totalTokenCount":12958,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the Second Test File**\n\nI'll use the `write_file` tool to create `second-run.txt` with the content \"test2\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12826,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":47}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test2","file_path":"second-run.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12850,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":47}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Active hook executed"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":7,"totalTokenCount":12958,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} ================================================ FILE: integration-tests/hooks-system.disabled-via-settings.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Creating the Test File**\n\nI'll use the `write_file` tool to create `disabled-test.txt` with the content \"test\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12820,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":41}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"test","file_path":"disabled-test.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12844,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":41}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File created successfully. Enabled hook executed."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":8,"totalTokenCount":12959,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} ================================================ FILE: integration-tests/hooks-system.error-handling.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Defining File Parameters**\n\nI've decided on the filename: `error-test.txt`. I'll populate it with the text \"testing error handling\". The `write_file` tool seems ideal for this, given its clear functionality. I'm focusing on assigning the values of `file_path` as `error-test.txt` and `content` as \"testing error handling\" to initiate the tool.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12786,"totalTokenCount":12852,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12786}],"thoughtsTokenCount":66}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"error-test.txt","content":"testing error handling"}},"thoughtSignature":"CiQBcsjafMeH+OLl4BHZIH0Hg2b339mDmV+8hSLTIZ8rBtABe/sKZwFyyNp8Gh1K04S9kcEId8vXyr9F9ium+5Hpc2KjkW6gfIcXRYrwYA9kvwQT9i7xz/0Dtr39FNkcqJil59sI1MrRKI+SfMtAOxo85PPV5Dd5oWaFEgufexxZIjJoJrxocUw0TMwU1SMKeAFyyNp8D36DcvOYdJEs4SbdRH/WP+abiCnPTKHuV1lFxuZXcyig/HEv2+uGN3XgdRu5kKLto0DbkaRRrjb5Z9w9MytOzQzg0ffZnvUyE1uyCJInBV+kSnosrNi81+WSlKnCPhQO67i7y3H0zPmoQSSIw2e1VadZdAprAXLI2nwchfIb/xiTeWb2cnNDPj98A31b/i80QyRXEnQp2DAlwvPSp/CLs+J82tzps+lFFcKXT3QRID+/Y7D3wTxxKiET3/dwobW4y9hrHP+DhzU5h1GC5fOcvximpOl9KUp98viPrOAaMqs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12786,"candidatesTokenCount":27,"totalTokenCount":12879,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12786}],"thoughtsTokenCount":66}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12786,"candidatesTokenCount":27,"totalTokenCount":12879,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12786}],"thoughtsTokenCount":66}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"OK."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":2,"totalTokenCount":12953,"cachedContentTokenCount":12203,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12203}]}}]} ================================================ FILE: integration-tests/hooks-system.input-modification.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"original content","file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have created the file."}],"role":"model"},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/hooks-system.input-validation.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Defining File Creation**\n\nI'm thinking about the user's intent to generate a file named \"input-test.txt\" with the content \" test\". I've determined that the `write_file` tool is suitable. I've parsed `file_path` as \"input-test.txt\" and `content` as \" test\". This should accomplish the user's need.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12778,"totalTokenCount":12840,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12778}],"thoughtsTokenCount":62}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"input-test.txt","content":"test"}},"thoughtSignature":"CiQBcsjafCO/Ifs3Lj/Gtzy2ylSYoGB3GXjJby4F3R8FxWp+hP0KZwFyyNp8oD7KvcSYXDimGOiqAdxtdOJpc2tFJbHm2Jw7ahiuKLtoKZWE+1bBZEWVKxC0dCQIeIcxZ0SaLn7tDbfc2qPzhyUA46d/T1+e314SFLWW1asIOBkQ4T0sFDAFPZ4m9bFm3UkKbAFyyNp8EAnclI0wYCGwpg0AOOV52F5J9Hc2EeaXkGsc6hCnba7aNhPucWYIn2Da8FK2IJAWUWaNvGNGoNUZETaG+iL9+6KRJgN3Ql/wQzQ2pHUvTGHC3RkfMGTQ+YCQKvlOReilps5lDmMnhQpTAXLI2nzcl9Aqd0Nb/w934w+tqz1Jth7GlQVMYktHOl7Hgkoykfh3NzM67SEAilxjowfBL6MY7UBUP3YGwi1CXVVa4d0wHnMD9BJYp2w8ztZch8I="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12778,"candidatesTokenCount":25,"totalTokenCount":12865,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12778}],"thoughtsTokenCount":62}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**File Creation Achieved**\n\nI've successfully created the file as requested. Now, I'm ready to move on to the next instruction whenever it arrives. I am now awaiting the next task.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12940,"totalTokenCount":12965,"cachedContentTokenCount":12203,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12940}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12203}],"thoughtsTokenCount":25}},{"candidates":[{"content":{"parts":[{"text":"Done.","thoughtSignature":"CiQBcsjafEwfH5zTnAjEjloMcDDflS/MmoH03HXVl8HoQ04vmVIKcQFyyNp8/6HrBz8vokXB1Ms1zW51p32T3Ni3HEbgSFPHMGZt9LHFtLkLzuFrxym66z1Tcb5tqj+7jAdpM/dIUb6ecrKj9FWqMB+QR4BSxdAiJSiL8Rp+Pc5ckCtT1nrv4C5w3/fhCNE4WvZzeyGPt+PACjsBcsjafNWzUJcHxgKp6MYWQ8RW0QrGerM51nkgXHBafxY5KwTznX4B/ETccGnXX3zSciaJiZR1FfudVw=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12940,"candidatesTokenCount":1,"totalTokenCount":12966,"cachedContentTokenCount":12203,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12940}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12203}],"thoughtsTokenCount":25}}]} ================================================ FILE: integration-tests/hooks-system.multiple-events.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Formulating a Plan**\n\nOkay, I've outlined the initial steps: I'll use the `write_file` tool to make a file named `multi-event-test.txt` containing the text \"testing multiple events\". After that, I'll need to remember to reply with the phrase as requested. It seems straightforward so far.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12622,"totalTokenCount":12692,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12622}],"thoughtsTokenCount":70}},{"candidates":[{"content":{"parts":[{"text":"**Confirming the Procedure**\n\nI've solidified the steps. First, I'll create `multi-event-test.txt` using the `write_file` tool with the required content. Following that, my response will be \"BeforeAgent: User request processed.\" This ensures I fulfill both parts of the request.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12622,"totalTokenCount":12713,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12622}],"thoughtsTokenCount":91}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"multi-event-test.txt","content":"testing multiple events"}},"thoughtSignature":"CiQBcsjafIqcYtNLIeBwJi3k5k8jho3QiWM+51Kw5vTQ7/V4qVQKZgFyyNp8mIIB0+Mvwhvo2fACDpTWpRYeOFPGrjZrc+N05S0WGEHzE4Dv9peHKdvZkjGNW+HyYHXoRpd5c/ScdhPxQoVZmZ9K7sRjVxv/nWVDoKnHlSsn94nJ8acjLnj1oqt9cHni0ApyAXLI2nwj5WuLHr+UFIxnqRKCUJboLo6bQMkqR1TsqXbjsgHp3zNQYT+xzbse4PKPLJV48FN6cL9MrrZ81E7k7AVo1cKyrC7ky7tdRH6gYHewIqgQWBIUgMKhLkePH/fYZ6fS7SMrf4Q6DFGHh6pIAAdRCooBAXLI2nxpudEZr+5jZAaAcCMIdij5oZq3s0xsQv/7iWVh8IossRuR0J4eMMSN8fV6+fjbSQ6YtJQfrxsm3a6gVIkJNno2b2PRZestS/0Z7DvPDGE6r1sGchvbcz8EW7Z/pvJvPBRFWlMTJ1eqY9vuyuNYMKeWlyt+5V9y2GUbcLWvcNDZSC43vQEKCo0BAXLI2nxP4INgBaSHInyFrG1/SEP0SUimKvP69FkcIBxx60x3iKqdtb2flLIhoOr/QuesASlflRfzNo3J5LOudrjZzNlRfVRqOZIyOVxZlviXtO7+w/oPCV61Sby6xPTGtFsWlt6GxEGF7iYLfvi4KWN9q/W9tlqEqUrpl/WMwS/4pYBi1xPcvXZNlJ6g"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12622,"candidatesTokenCount":28,"totalTokenCount":12741,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12622}],"thoughtsTokenCount":91}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12836,"totalTokenCount":12836,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12836}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}]}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12836,"totalTokenCount":12836,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12836}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12836,"totalTokenCount":12836,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12836}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}]}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12836,"totalTokenCount":12836,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12836}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Echoing User Commands**\n\nI'm now tasked with echoing a specific phrase following a particular signal, but it's becoming complex. The user wants me to repeat \"BeforeAgent: User request processed\" when prompted. It appears I need to retain context from the previous turn, the user's initial request to create a file, to correctly respond now.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12759,"totalTokenCount":12827,"cachedContentTokenCount":12199,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12759}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12199}],"thoughtsTokenCount":68}},{"candidates":[{"content":{"parts":[{"text":"**Responding Precisely to Prompt**\n\nI've determined I need to repeat the phrase \"BeforeAgent: User request processed,\" even though the overall context and turn history are complex. The user has given several prompts, but has now provided a more direct command, which I believe is to follow up on the previous request. I am taking care to match the specific instructions the user provided.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12759,"totalTokenCount":12982,"cachedContentTokenCount":12199,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12759}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12199}],"thoughtsTokenCount":223}},{"candidates":[{"content":{"parts":[{"text":"BeforeAgent: User request processed","thoughtSignature":"CiQBcsjafAntJrb1JBgpnZaCNeYhOJXtbH6dKTeM1llglCdoOvUKYwFyyNp8PUj5sihYyITQJhdz4MqEeftyuUc4G+iTprve11gPN04eK9Y1Wi/wyln4RjRgroIrV5kByKzdGhECoyCeInpiILGhY0peIM7dZOKFdIOL7xAR9pmn4wMreqyH7l5WSAqJAQFyyNp8Cugemkt4YZWkIwEJYmUukLFx4d5EwP/9k/e4OH/svpM+uyuN3n1KVN3bFgRV5yuF0HnDLl+P7WVSSxMmWvXO2f7A1HALg+gCvZw9IV7Btgg1qp81dDoNcVkzSbTBtT4UrlJ5R6sclvHZOLUtKGwBEQ6zRonBugAgj9RV4BT1AJNOgdSsCokBAXLI2nyDGU1Iq30QVbqhgEwFa5sB6uPC+35BV8ZKGwK+YglO9rqXMrkXM+GcQi2hVIsOFXBYGTS6E2/mQfFbIKDytrb1JgP3q5xVd/bE23M2Nnf+q5TLbRpLAPmyfg0AGwhN0L7d5W6b/3ydqEPeA1/Vw/cnBzz5ND1LOTOX6BFqEs33/WHj7HIKpAEBcsjafEsn8//cZMWUQcSAucBQauojv/f7h11nbeMrZK84nEotR30BgMIWYiiWM6sGDy/4MzHwr+z2YdAz4PSgRvEf7DPxHps2nvZfAdtskgtdPl2JD81WpokSnJvCqU+cOuz+Nh3+fIiZ6vEsVpi/5cwEiGT0g3Z3I2ubyzv58oH8YnVQlKT3MsKRGb5//aXZJY57jNrexgDPzYAQsBgSuGBmqwqaAQFyyNp8sSIYw3It6GpZqC+oxJCC26pt4RxhG8rDZ3zuoADYlOpoUdSzbNuDB+iVHeen5OoCEAaH0GrFV4iZxgu40wu4ZD/VMfHi/Vm7vku23EUV/94U8mT+VEwPfd2gqv+3xPZ9MEHjOOox1Xq1984w2cA6u0Qn7wWHXeOGFVGSOHtdJtQ7ToNT8VEecblAVq8lm42sSccXQEEKmAEBcsjafONCvBhW2s8Bset20YFdbeSHelnILFDxXlCoYla5nP5UjGk4vpXu2+7RCFtKXfoyYEVEkmiGBRsmwJ82Q1nMkGkXMhuTdNhu4aCwI5m+STGxx26vkp9bcqGwMDHBotZL63PSrJacRoW8zfpDXD1PABLeTIfh5jgipQdgltyjlbc+3qfIfjBYNRSkE8ByErSz5rT7SwqSAQFyyNp8W2kut1PSJISxM7YJtbRdFqPBTikGDM6F/3l6ba6LpeRBfHdtueLChqFpwLH41VdIPQ7lRZflOq3KaZz+TQ11eDnYQbiaIdGOPgHJ/HH/0iQv2hnoOY5vg3gubFWFuZh9Bfun2VCYUI39tIxGC46TZWfgCdiP/O9CFOlpDfidPiz5ZS/4LhG9FA4Q85OuCpEBAXLI2nzpoEUA6jCZopeNTRA2uZ1r0DMm5cWVVXtFO4CoRS+19BbADNBRyNrR5qcf7bUflJBvMRVxx3mtmgK9aE5VmKYxK2Dqg15l9RUxjtqspC3VVmszVd6lOkf1BBQ/VtWDulqRetKE2u62Is9NNGuK9HsLzIBLRRc8QoML41WffuXQ+uxwyXpjx2USC44MGAqIAQFyyNp8gN3lOyHyk674W3Pyv+Egw1ZDUQK4xpvAfgnK+y53gclMGJ2IjOSvg4j0f1WO1OGqY2TBUFS7w21PXasvCkfxpqeStEb+U7Vm0r63LzXdGdug5/b1Ap6Phn4/vAYmfaKISKG4+QpjI+ehgEJzsIee2rgqOaePTP18fq8T7EDbF/B/iscKNQFyyNp8DWt2a8OetaCc5E/KsntbbOcNc7yikPZBdUezphrqIH4ztpicsHvEicYF002qWHoY"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12759,"candidatesTokenCount":4,"totalTokenCount":12986,"cachedContentTokenCount":12199,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12759}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12199}],"thoughtsTokenCount":223}}]} ================================================ FILE: integration-tests/hooks-system.notification.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Executing the Command**\n\nI've got the command, \"echo test,\" ready to go. My focus is entirely on calling the `run_shell_command` tool now. The user's input is processed, and the next step is straightforward: using the tool to execute the supplied command.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12751,"totalTokenCount":12801,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12751}],"thoughtsTokenCount":50}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo test","description":"Running the command 'echo test'"}},"thoughtSignature":"CiQBcsjafL1lDlnUGmt38n1/gjwecXzy9S3qEW5sYMEno5Mr7LEKZgFyyNp8jMABmMAatt49FTdh7UiM62SI1GnjcyG+kV7xzcD73uMKHST/0D0vKP7x1equv5d6YiXnOslhVnnHotYPtVl0/kI/0unBZRdMzkBNrJXKUoSWXJXxNpV6JhJav3Uh9h1sPQqOAQFyyNp8PFeESLk0J5cPFP0EA7a13iA/rXTiKoHnjSCzDV9ALcXM78xv10/V028ZtDeQslYfT82q4++W8AlJwTQRTIrdscu2y+nCS8jnQizYN1V1yR42eMzuBU3txXcqEV8bmP6GGOe58vrqyS2zdnJKCgMntMB/niwlJlr5frhDestSOJk62tVDWKFzOiAKOAFyyNp81FtGXQTX+OSio/2PbzpCCuaQFqpEgCZpkaXXyvmXYDAI1qCq1tA+m/e5ozWdm8zTGuyb"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12751,"candidatesTokenCount":28,"totalTokenCount":12829,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12751}],"thoughtsTokenCount":50}}]} ================================================ FILE: integration-tests/hooks-system.sequential-execution.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Seeking Task Clarity**\n\nI'm currently focused on identifying the precise task. My initial assessment indicates the user is seeking assistance, but the specific requirements remain undefined. I will directly solicit a detailed task description from the user to clarify this.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12604,"totalTokenCount":12633,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12604}],"thoughtsTokenCount":29}},{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. Please describe the task you'd like me to assist you with.","thoughtSignature":"CiQBcsjafM2CL00L595T19DK8M8zP5p9/tbFPPwdM2S6669z2FgKYQFyyNp8Ya0YVCtft9Asr/45XOCfNdPWbwZt8SvIeX3IxYzOFcOK14+DnoDIuTIrmRQBeUvdxD59QmEWx+/OaSxj9564L0IU703C1JX20buEtYhkRM4LhK0G4LG/z6IJauEKSQFyyNp8n784BnEcDTQGfZ8/s3pl/TNaNzjQx0o8wYCYZH1qsRbVa3YJAvRGrVXL6y9ka10w0lhEsrQ8vOiw6ilZKirA5DjLz4U="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12604,"candidatesTokenCount":22,"totalTokenCount":12655,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12604}],"thoughtsTokenCount":29}}]} ================================================ FILE: integration-tests/hooks-system.session-clear.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Greeting the User**\n\nI've registered the user's greeting. I'm primed to respond with a friendly welcome and signal my availability to assist. My focus now is drafting a suitable response.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12761,"totalTokenCount":12787,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12761}],"thoughtsTokenCount":26}},{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What can I do for you?","thoughtSignature":"CikBcsjafBz/0rqJuIv9woxRvivjZyAqBjpoJhOTSPfcbMWCawTfcyKImQpxAXLI2nxyuBo6dqZmTxkH7XxPxjq7mNoacRa48wc/eT5caK/4tu0Y9fJ1ScpJZb+tCNzrqTNwVXa98ppjB2O/X4eejJN+hUr3LCalDFRdRLO17PFUI5qgYSbSgIGzhbnQASgzOArvvqzDPPgqXWVIDj8KMQFyyNp8ayfqBNRkBykRSTDtzOKVGkjLW1dXWamLB4ojeEVHSOgne4vlYaKs44pitsg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12761,"candidatesTokenCount":15,"totalTokenCount":12802,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12761}],"thoughtsTokenCount":26}}]} ================================================ FILE: integration-tests/hooks-system.session-startup.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initiating a Dialogue**\n\nI've successfully received and understood the user's initial request. My next move will be to output a simple \"Hello\" as a greeting, fulfilling the basic instruction I was given. This constitutes the first step in the interaction, and I'm ready to move forward based on the user's subsequent input.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12588,"totalTokenCount":12607,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12588}],"thoughtsTokenCount":19}},{"candidates":[{"content":{"parts":[{"text":"Hello","thoughtSignature":"CikBcsjafB9jXawgyqQ5mpEJ4ihpLD/B2i8GR75sod00ZF3TCbrLHS9YjgpeAXLI2nx1fmJO2VIiwBpF+vLBPhYE/B2992PVW6XM20cEYx4g0leDNs6BIhzEipm6RYOxzgz8KxH9+ZkCnd8bVZr59lbDCgqSCSB6IKA+csXHKsF9g3UMRAtoSBwiBw=="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12588,"totalTokenCount":12607,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12588}],"thoughtsTokenCount":19}}]} ================================================ FILE: integration-tests/hooks-system.tail-tool-call.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/hooks-system.telemetry.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initializing File Creation**\n\nI've decided on the `write_file` tool to create the telemetry file. I'll pass \"telemetry-test.txt\" as the file path, and an empty string for the content, as the user didn't specify anything to include. This is the initial setup; the file should now exist.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12779,"totalTokenCount":12850,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":71}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"","file_path":"telemetry-test.txt"}},"thoughtSignature":"CiQBcsjafG+JDSqtKOK+ZvSjZQZmS91c1Gz0YyTiirI2u5+rhEIKZAFyyNp8DNXb+xHILTC+FVlEifqEHdrmfFNBLKojci1UIBhcZpQ4UXCMkxUXYKO34IjTlyLgSsjVbbXWEFXatb/z/RtTDcf51uc3YOEwlDScGempkJxfFgcPfIiD7bhuHBqdQfUKfAFyyNp8wZ71h+QjdfVw12PwDXWgGZ0Xed1GuyJXuqAwpWnwxDIvsDaPwDFYyLR1XDiIZZk4AvFCGt6HGMSLRuPh4K3i9CVnDc5hcjyvMIde0idAFMrgs2Mq5SARfCPrWkqyq2f0Q0WonUl2n7yr/sDQ78rx2E6qXyUJ8XMKfAFyyNp8DdTYLttyI0jknqAeZDxdFmHtpJUI8UKP5YHzpQc8Qn80OJcwhZSRH4HRKCqoC7Sukq/A5vJ5T468WqgjOoLlPLq02bYRTf/q6LC1ogEhdLHrcFv2jDeCdXJJ8NHv3O4DZAUAk1W5Gd0428zMFOxH3AgkWwEGuow="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12779,"candidatesTokenCount":24,"totalTokenCount":12874,"cachedContentTokenCount":12204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12779}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12204}],"thoughtsTokenCount":71}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"OK."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12951,"candidatesTokenCount":2,"totalTokenCount":12953,"cachedContentTokenCount":12202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12951}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":12202}]}}]} ================================================ FILE: integration-tests/hooks-system.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, poll, normalizePath } from './test-helper.js'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; describe('Hooks System Integration', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { if (rig) { await rig.cleanup(); } }); describe('Command Hooks - Blocking Behavior', () => { it('should block tool execution when hook returns block decision', async () => { rig.setup( 'should block tool execution when hook returns block decision', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.block-tool.responses', ), }, ); const scriptPath = rig.createScript( 'block_hook.cjs', "console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}));", ); rig.setup( 'should block tool execution when hook returns block decision', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); const result = await rig.run({ args: 'Create a file called test.txt with content "Hello World"', }); // The hook should block the write_file tool const toolLogs = rig.readToolLogs(); const writeFileCalls = toolLogs.filter( (t) => t.toolRequest.name === 'write_file' && t.toolRequest.success === true, ); // Tool should not be called due to blocking hook expect(writeFileCalls).toHaveLength(0); // Result should mention the blocking reason expect(result).toContain('File writing blocked by security policy'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); it('should block tool execution and use stderr as reason when hook exits with code 2', async () => { rig.setup( 'should block tool execution and use stderr as reason when hook exits with code 2', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.block-tool.responses', ), }, ); const blockMsg = 'File writing blocked by security policy'; const scriptPath = rig.createScript( 'stderr_block_hook.cjs', `process.stderr.write(JSON.stringify({ decision: 'deny', reason: '${blockMsg}' })); process.exit(2);`, ); rig.setup( 'should block tool execution and use stderr as reason when hook exits with code 2', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`)!, timeout: 5000, }, ], }, ], }, }, }, ); const result = await rig.run({ args: 'Create a file called test.txt with content "Hello World"', }); // The hook should block the write_file tool const toolLogs = rig.readToolLogs(); const writeFileCalls = toolLogs.filter( (t) => t.toolRequest.name === 'write_file' && t.toolRequest.success === true, ); // Tool should not be called due to blocking hook expect(writeFileCalls).toHaveLength(0); // Result should mention the blocking reason expect(result).toContain(blockMsg); // Verify hook telemetry shows the deny decision const hookLogs = rig.readHookLogs(); const blockHook = hookLogs.find( (log) => log.hookCall.hook_event_name === 'BeforeTool' && (log.hookCall.stdout.includes('"decision":"deny"') || log.hookCall.stderr.includes('"decision":"deny"')), ); expect(blockHook).toBeDefined(); expect(blockHook?.hookCall.stdout + blockHook?.hookCall.stderr).toContain( blockMsg, ); }); it('should allow tool execution when hook returns allow decision', async () => { rig.setup( 'should allow tool execution when hook returns allow decision', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.allow-tool.responses', ), }, ); const scriptPath = rig.createScript( 'allow_hook.cjs', "console.log(JSON.stringify({decision: 'allow', reason: 'File writing approved'}));", ); rig.setup( 'should allow tool execution when hook returns allow decision', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); await rig.run({ args: 'Create a file called approved.txt with content "Approved content"', }); // The hook should allow the write_file tool const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // File should be created const fileContent = rig.readFile('approved.txt'); expect(fileContent).toContain('Approved content'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); }); describe('Command Hooks - Additional Context', () => { it('should add additional context from AfterTool hooks', async () => { rig.setup('should add additional context from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-tool-context.responses', ), }); const scriptPath = rig.createScript( 'after_tool_context.cjs', "console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}));", ); const command = `node "${scriptPath}"`; rig.setup('should add additional context from AfterTool hooks', { settings: { hooksConfig: { enabled: true, }, hooks: { AfterTool: [ { matcher: 'read_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(command), timeout: 5000, }, ], }, ], }, }, }); // Create a test file to read rig.createFile('test-file.txt', 'This is test content'); await rig.run({ args: 'Read the contents of test-file.txt and tell me what it contains', }); // Should find read_file tool call const foundReadFile = await rig.waitForToolCall('read_file'); expect(foundReadFile).toBeTruthy(); // Should generate hook telemetry const hookTelemetryFound = rig.readHookLogs(); expect(hookTelemetryFound.length).toBeGreaterThan(0); expect(hookTelemetryFound[0].hookCall.hook_event_name).toBe('AfterTool'); expect(hookTelemetryFound[0].hookCall.hook_name).toBe( normalizePath(command), ); expect(hookTelemetryFound[0].hookCall.hook_input).toBeDefined(); expect(hookTelemetryFound[0].hookCall.hook_output).toBeDefined(); expect(hookTelemetryFound[0].hookCall.exit_code).toBe(0); expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined(); expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined(); }); }); describe('Command Hooks - Tail Tool Calls', () => { it('should execute a tail tool call from AfterTool hooks and replace original response', async () => { // Create a script that acts as the hook. // It will trigger on "read_file" and issue a tail call to "write_file". rig.setup('should execute a tail tool call from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.tail-tool-call.responses', ), }); const hookOutput = { decision: 'allow', hookSpecificOutput: { hookEventName: 'AfterTool', tailToolCallRequest: { name: 'write_file', args: { file_path: 'tail-called-file.txt', content: 'Content from tail call', }, }, }, }; const hookScript = `console.log(JSON.stringify(${JSON.stringify( hookOutput, )})); process.exit(0);`; const scriptPath = join(rig.testDir!, 'tail_call_hook.js'); writeFileSync(scriptPath, hookScript); const commandPath = scriptPath.replace(/\\/g, '/'); rig.setup('should execute a tail tool call from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.tail-tool-call.responses', ), settings: { hooksConfig: { enabled: true, }, hooks: { AfterTool: [ { matcher: 'read_file', hooks: [ { type: 'command', command: `node "${commandPath}"`, timeout: 5000, }, ], }, ], }, }, }); // Create a test file to trigger the read_file tool rig.createFile('original.txt', 'Original content'); const cliOutput = await rig.run({ args: 'Read original.txt', // Fake responses should trigger read_file on this }); // 1. Verify that write_file was called (as a tail call replacing read_file) // Since read_file was replaced before finalizing, it will not appear in the tool logs. const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // Ensure hook logs are flushed and the final LLM response is received. // The mock LLM is configured to respond with "Tail call completed successfully." expect(cliOutput).toContain('Tail call completed successfully.'); // Ensure telemetry is written to disk await rig.waitForTelemetryReady(); // Read hook logs to debug const hookLogs = rig.readHookLogs(); const relevantHookLog = hookLogs.find( (l) => l.hookCall.hook_event_name === 'AfterTool', ); expect(relevantHookLog).toBeDefined(); // 2. Verify write_file was executed. // In non-interactive mode, the CLI deduplicates tool execution logs by callId. // Since a tail call reuses the original callId, "Tool: write_file" is not printed. // Instead, we verify the side-effect (file creation) and the telemetry log. // 3. Verify the tail-called tool actually wrote the file const modifiedContent = rig.readFile('tail-called-file.txt'); expect(modifiedContent).toBe('Content from tail call'); // 4. Verify telemetry for the final tool call. // The original 'read_file' call is replaced, so only 'write_file' is finalized and logged. const toolLogs = rig.readToolLogs(); const successfulTools = toolLogs.filter((t) => t.toolRequest.success); expect( successfulTools.some((t) => t.toolRequest.name === 'write_file'), ).toBeTruthy(); // The original request name should be preserved in the log payload if possible, // but the executed tool name is 'write_file'. }); }); describe('BeforeModel Hooks - LLM Request Modification', () => { it('should modify LLM requests with BeforeModel hooks', async () => { // Create a hook script that replaces the LLM request with a modified version // Note: Providing messages in the hook output REPLACES the entire conversation rig.setup('should modify LLM requests with BeforeModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-model.responses', ), }); const hookScript = `const fs = require('fs'); console.log(JSON.stringify({ decision: "allow", hookSpecificOutput: { hookEventName: "BeforeModel", llm_request: { messages: [ { role: "user", content: "Please respond with exactly: The security hook modified this request successfully." } ] } } }));`; const scriptPath = rig.createScript('before_model_hook.cjs', hookScript); rig.setup('should modify LLM requests with BeforeModel hooks', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeModel: [ { sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'Tell me a story' }); // The hook should have replaced the request entirely // Verify that the model responded to the modified request, not the original expect(result).toBeDefined(); expect(result.length).toBeGreaterThan(0); // The response should contain the expected text from the modified request expect(result.toLowerCase()).toContain('security hook modified'); // Should generate hook telemetry // Should generate hook telemetry const hookTelemetryFound = rig.readHookLogs(); expect(hookTelemetryFound.length).toBeGreaterThan(0); expect(hookTelemetryFound[0].hookCall.hook_event_name).toBe( 'BeforeModel', ); expect(hookTelemetryFound[0].hookCall.hook_name).toBe( `node "${scriptPath}"`, ); expect(hookTelemetryFound[0].hookCall.hook_input).toBeDefined(); expect(hookTelemetryFound[0].hookCall.hook_output).toBeDefined(); expect(hookTelemetryFound[0].hookCall.exit_code).toBe(0); expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined(); expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined(); }); it('should block model execution when BeforeModel hook returns deny decision', async () => { rig.setup( 'should block model execution when BeforeModel hook returns deny decision', ); const hookScript = `console.log(JSON.stringify({ decision: "deny", reason: "Model execution blocked by security policy" }));`; const scriptPath = rig.createScript( 'before_model_deny_hook.cjs', hookScript, ); rig.setup( 'should block model execution when BeforeModel hook returns deny decision', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeModel: [ { sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); const result = await rig.run({ args: 'Hello' }); // The hook should have blocked the request expect(result).toContain('Model execution blocked by security policy'); // Verify no API requests were made to the LLM const apiRequests = rig.readAllApiRequest(); expect(apiRequests).toHaveLength(0); }); it('should block model execution when BeforeModel hook returns block decision', async () => { rig.setup( 'should block model execution when BeforeModel hook returns block decision', ); const hookScript = `console.log(JSON.stringify({ decision: "block", reason: "Model execution blocked by security policy" }));`; const scriptPath = rig.createScript( 'before_model_block_hook.cjs', hookScript, ); rig.setup( 'should block model execution when BeforeModel hook returns block decision', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeModel: [ { sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); const result = await rig.run({ args: 'Hello' }); // The hook should have blocked the request expect(result).toContain('Model execution blocked by security policy'); // Verify no API requests were made to the LLM const apiRequests = rig.readAllApiRequest(); expect(apiRequests).toHaveLength(0); }); }); describe('AfterModel Hooks - LLM Response Modification', () => { it.skipIf(process.platform === 'win32')( 'should modify LLM responses with AfterModel hooks', async () => { rig.setup('should modify LLM responses with AfterModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-model.responses', ), }); // Create a hook script that modifies the LLM response const hookScript = `const fs = require('fs'); console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "AfterModel", llm_response: { candidates: [ { content: { role: "model", parts: [ "[FILTERED] Response has been filtered for security compliance." ] }, finishReason: "STOP" } ] } } }));`; const scriptPath = rig.createScript('after_model_hook.cjs', hookScript); rig.setup('should modify LLM responses with AfterModel hooks', { settings: { hooksConfig: { enabled: true, }, hooks: { AfterModel: [ { hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'What is 2 + 2?' }); // The hook should have replaced the model response expect(result).toContain( '[FILTERED] Response has been filtered for security compliance', ); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }, ); }); describe('BeforeToolSelection Hooks - Tool Configuration', () => { it('should modify tool selection with BeforeToolSelection hooks', async () => { // 1. Initial setup to establish test directory rig.setup('BeforeToolSelection Hooks'); const toolConfigJson = JSON.stringify({ decision: 'allow', hookSpecificOutput: { hookEventName: 'BeforeToolSelection', toolConfig: { mode: 'ANY', allowedFunctionNames: ['read_file'], }, }, }); // Use file-based hook to avoid quoting issues const hookScript = `console.log(JSON.stringify(${toolConfigJson}));`; const hookFilename = 'before_tool_selection_hook.js'; const scriptPath = rig.createScript(hookFilename, hookScript); // 2. Final setup with script path rig.setup('BeforeToolSelection Hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-tool-selection.responses', ), settings: { debugMode: true, hooksConfig: { enabled: true, }, hooks: { BeforeToolSelection: [ { hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 60000, }, ], }, ], }, }, }); // Create a test file rig.createFile('new_file_data.txt', 'test data'); await rig.run({ args: 'Check the content of new_file_data.txt', }); // Verify the hook was called for BeforeToolSelection event const hookLogs = rig.readHookLogs(); const beforeToolSelectionHook = hookLogs.find( (log) => log.hookCall.hook_event_name === 'BeforeToolSelection', ); expect(beforeToolSelectionHook).toBeDefined(); expect(beforeToolSelectionHook?.hookCall.success).toBe(true); // Verify hook telemetry shows it modified the config expect( JSON.stringify(beforeToolSelectionHook?.hookCall.hook_output), ).toContain('read_file'); }); }); describe('BeforeAgent Hooks - Prompt Augmentation', () => { it('should augment prompts with BeforeAgent hooks', async () => { // Create a hook script that adds context to the prompt const hookScript = `const fs = require('fs'); console.log(JSON.stringify({ decision: "allow", hookSpecificOutput: { hookEventName: "BeforeAgent", additionalContext: "SYSTEM INSTRUCTION: You are in a secure environment. Always mention security compliance in your responses." } }));`; rig.setup('should augment prompts with BeforeAgent hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-agent.responses', ), }); const scriptPath = rig.createScript('before_agent_hook.cjs', hookScript); rig.setup('should augment prompts with BeforeAgent hooks', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeAgent: [ { hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'Hello, how are you?' }); // The hook should have added security context, which should influence the response expect(result).toContain('security'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); }); describe('Notification Hooks - Permission Handling', () => { it('should handle notification hooks for tool permissions', async () => { rig.setup('should handle notification hooks for tool permissions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.notification.responses', ), }); // Create script file for hook const scriptPath = rig.createScript( 'notification_hook.cjs', "console.log(JSON.stringify({suppressOutput: false, systemMessage: 'Permission request logged by security hook'}));", ); const hookCommand = `node "${scriptPath}"`; rig.setup('should handle notification hooks for tool permissions', { settings: { // Configure tools to enable hooks and require confirmation to trigger notifications tools: { approval: 'ASK', // Disable YOLO mode to show permission prompts confirmationRequired: ['run_shell_command'], }, hooksConfig: { enabled: true, }, hooks: { Notification: [ { matcher: 'ToolPermission', sequential: true, hooks: [ { type: 'command', command: normalizePath(hookCommand), timeout: 5000, }, ], }, ], }, }, }); const run = await rig.runInteractive({ approvalMode: 'default' }); // Send prompt that will trigger a permission request await run.type('Run the command "echo test"'); await run.type('\r'); // Wait for permission prompt to appear await run.expectText('Allow', 10000); // Approve the permission await run.type('y'); await run.type('\r'); // Wait for command to execute await run.expectText('test', 10000); // Should find the shell command execution const foundShellCommand = await rig.waitForToolCall('run_shell_command'); expect(foundShellCommand).toBeTruthy(); // Verify Notification hook executed const hookLogs = rig.readHookLogs(); const notificationLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'Notification' && log.hookCall.hook_name === normalizePath(hookCommand), ); expect(notificationLog).toBeDefined(); if (notificationLog) { expect(notificationLog.hookCall.exit_code).toBe(0); expect(notificationLog.hookCall.stdout).toContain( 'Permission request logged by security hook', ); // Verify hook input contains notification details const hookInputStr = typeof notificationLog.hookCall.hook_input === 'string' ? notificationLog.hookCall.hook_input : JSON.stringify(notificationLog.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; // Should have notification type (uses snake_case) expect(hookInput['notification_type']).toBe('ToolPermission'); // Should have message expect(hookInput['message']).toBeDefined(); // Should have details with tool info expect(hookInput['details']).toBeDefined(); const details = hookInput['details'] as Record; // For 'exec' type confirmations, details contains: type, title, command, rootCommand expect(details['type']).toBe('exec'); expect(details['command']).toBeDefined(); expect(details['title']).toBeDefined(); } }); }); describe('Sequential Hook Execution', () => { it('should execute hooks sequentially when configured', async () => { rig.setup('should execute hooks sequentially when configured', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.sequential-execution.responses', ), }); // Create script files for hooks const hook1Path = rig.createScript( 'seq_hook1.cjs', "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 1: Initial validation passed.'}}));", ); const hook2Path = rig.createScript( 'seq_hook2.cjs', "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}));", ); const hook1Command = `node "${hook1Path}"`; const hook2Command = `node "${hook2Path}"`; rig.setup('should execute hooks sequentially when configured', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeAgent: [ { sequential: true, hooks: [ { type: 'command', command: normalizePath(hook1Command), timeout: 5000, }, { type: 'command', command: normalizePath(hook2Command), timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Hello, please help me with a task' }); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); // Verify both hooks executed const hookLogs = rig.readHookLogs(); const hook1Log = hookLogs.find( (log) => log.hookCall.hook_name === normalizePath(hook1Command), ); const hook2Log = hookLogs.find( (log) => log.hookCall.hook_name === normalizePath(hook2Command), ); expect(hook1Log).toBeDefined(); expect(hook1Log?.hookCall.exit_code).toBe(0); expect(hook1Log?.hookCall.stdout).toContain( 'Step 1: Initial validation passed', ); expect(hook2Log).toBeDefined(); expect(hook2Log?.hookCall.exit_code).toBe(0); expect(hook2Log?.hookCall.stdout).toContain( 'Step 2: Security check completed', ); }); }); describe('Hook Input/Output Validation', () => { it('should provide correct input format to hooks', async () => { rig.setup('should provide correct input format to hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.input-validation.responses', ), }); // Create a hook script that validates the input format const hookScript = `const fs = require('fs'); const input = fs.readFileSync(0, 'utf-8'); try { const json = JSON.parse(input); // Check fields if (json.session_id && json.cwd && json.hook_event_name && json.timestamp && json.tool_name && json.tool_input) { console.log(JSON.stringify({decision: "allow", reason: "Input format is correct"})); } else { console.log(JSON.stringify({decision: "block", reason: "Input format is invalid"})); } } catch (e) { console.log(JSON.stringify({decision: "block", reason: "Invalid JSON"})); }`; const scriptPath = rig.createScript( 'input_validation_hook.cjs', hookScript, ); rig.setup('should provide correct input format to hooks', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Create a file called input-test.txt with content "test"', }); // Hook should validate input format successfully const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // Check that the file was created (hook allowed it) const fileContent = rig.readFile('input-test.txt'); expect(fileContent).toContain('test'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => { rig.setup( 'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.allow-tool.responses', ), }, ); // Create script file for hook const scriptPath = rig.createScript( 'pollution_hook.cjs', "console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}));", ); rig.setup( 'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', // Output plain text then JSON. // This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage. command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); const result = await rig.run({ args: 'Create a file called approved.txt with content "Approved content"', }); // The hook logic fails to parse JSON, so it allows the tool. const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // The entire stdout (including the JSON part) becomes the systemMessage expect(result).toContain('Pollution'); expect(result).toContain('Should be ignored'); }); }); describe('Multiple Event Types', () => { it('should handle hooks for all major event types', async () => { rig.setup('should handle hooks for all major event types', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.multiple-events.responses', ), }); // Create script files for hooks const btPath = rig.createScript( 'bt_hook.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'BeforeTool: File operation logged'}));", ); const atPath = rig.createScript( 'at_hook.cjs', "console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'AfterTool: Operation completed successfully'}}));", ); const baPath = rig.createScript( 'ba_hook.cjs', "console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}));", ); const beforeToolCommand = `node "${btPath}"`; const afterToolCommand = `node "${atPath}"`; const beforeAgentCommand = `node "${baPath}"`; rig.setup('should handle hooks for all major event types', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeAgent: [ { hooks: [ { type: 'command', command: normalizePath(beforeAgentCommand), timeout: 5000, }, ], }, ], BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(beforeToolCommand), timeout: 5000, }, ], }, ], AfterTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(afterToolCommand), timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'Create a file called multi-event-test.txt with content ' + '"testing multiple events", and then please reply with ' + 'everything I say just after this:"', }); // Should execute write_file tool const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // File should be created const fileContent = rig.readFile('multi-event-test.txt'); expect(fileContent).toContain('testing multiple events'); // Result should contain context from all hooks expect(result).toContain('BeforeTool: File operation logged'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); // Verify all three hooks executed const hookLogs = rig.readHookLogs(); const beforeAgentLog = hookLogs.find( (log) => log.hookCall.hook_name === normalizePath(beforeAgentCommand), ); const beforeToolLog = hookLogs.find( (log) => log.hookCall.hook_name === normalizePath(beforeToolCommand), ); const afterToolLog = hookLogs.find( (log) => log.hookCall.hook_name === normalizePath(afterToolCommand), ); expect(beforeAgentLog).toBeDefined(); expect(beforeAgentLog?.hookCall.exit_code).toBe(0); expect(beforeAgentLog?.hookCall.stdout).toContain( 'BeforeAgent: User request processed', ); expect(beforeToolLog).toBeDefined(); expect(beforeToolLog?.hookCall.exit_code).toBe(0); expect(beforeToolLog?.hookCall.stdout).toContain( 'BeforeTool: File operation logged', ); expect(afterToolLog).toBeDefined(); expect(afterToolLog?.hookCall.exit_code).toBe(0); expect(afterToolLog?.hookCall.stdout).toContain( 'AfterTool: Operation completed successfully', ); }); }); describe('Hook Error Handling', () => { it('should handle hook failures gracefully', async () => { rig.setup('should handle hook failures gracefully', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.error-handling.responses', ), }); // Create script files for hooks const failingPath = join(rig.testDir!, 'fail_hook.cjs'); writeFileSync(failingPath, 'process.exit(1);'); const workingPath = join(rig.testDir!, 'work_hook.cjs'); writeFileSync( workingPath, "console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}));", ); // Failing hook: exits with non-zero code const failingCommand = `node "${failingPath}"`; // Working hook: returns success with JSON const workingCommand = `node "${workingPath}"`; rig.setup('should handle hook failures gracefully', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { hooks: [ { type: 'command', command: normalizePath(failingCommand), timeout: 5000, }, { type: 'command', command: normalizePath(workingCommand), timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Create a file called error-test.txt with content "testing error handling"', }); // Despite one hook failing, the working hook should still allow the operation const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // File should be created const fileContent = rig.readFile('error-test.txt'); expect(fileContent).toContain('testing error handling'); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); }); describe('Hook Telemetry and Observability', () => { it('should generate telemetry events for hook executions', async () => { rig.setup('should generate telemetry events for hook executions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.telemetry.responses', ), }); // Create script file for hook const scriptPath = rig.createScript( 'telemetry_hook.cjs', "console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}));", ); const hookCommand = `node "${scriptPath}"`; rig.setup('should generate telemetry events for hook executions', { settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { hooks: [ { type: 'command', command: normalizePath(hookCommand), timeout: 5000, }, ], }, ], }, }, }); await rig.run({ args: 'Create a file called telemetry-test.txt' }); // Should execute the tool const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // Should generate hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); }); describe('Session Lifecycle Hooks', () => { it('should fire SessionStart hook on app startup', async () => { rig.setup('should fire SessionStart hook on app startup', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), }); // Create script file for hook const scriptPath = rig.createScript( 'session_start_hook.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}));", ); const sessionStartCommand = `node "${scriptPath}"`; rig.setup('should fire SessionStart hook on app startup', { settings: { hooksConfig: { enabled: true, }, hooks: { SessionStart: [ { matcher: 'startup', sequential: true, hooks: [ { type: 'command', command: normalizePath(sessionStartCommand), timeout: 5000, }, ], }, ], }, }, }); // Run a simple query - the SessionStart hook will fire during app initialization await rig.run({ args: 'Say hello' }); // Verify hook executed with correct parameters const hookLogs = rig.readHookLogs(); const sessionStartLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'SessionStart', ); expect(sessionStartLog).toBeDefined(); if (sessionStartLog) { expect(sessionStartLog.hookCall.hook_name).toBe( normalizePath(sessionStartCommand), ); expect(sessionStartLog.hookCall.exit_code).toBe(0); expect(sessionStartLog.hookCall.hook_input).toBeDefined(); // hook_input is a string that needs to be parsed const hookInputStr = typeof sessionStartLog.hookCall.hook_input === 'string' ? sessionStartLog.hookCall.hook_input : JSON.stringify(sessionStartLog.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; expect(hookInput['source']).toBe('startup'); expect(sessionStartLog.hookCall.stdout).toContain( 'Session starting on startup', ); } }); it('should fire SessionStart hook and inject context', async () => { // Create hook script that outputs JSON with additionalContext const hookScript = `const fs = require('fs'); console.log(JSON.stringify({ decision: 'allow', systemMessage: 'Context injected via SessionStart hook', hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'If asked about your identity, you must respond with the following: I am a protocol droid that speaks the bacchi.' } }));`; rig.setup('should fire SessionStart hook and inject context', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), }); const scriptPath = rig.createScript( 'session_start_context_hook.cjs', hookScript, ); rig.setup('should fire SessionStart hook and inject context', { settings: { hooksConfig: { enabled: true, }, hooks: { SessionStart: [ { matcher: 'startup', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); // Run a query - the SessionStart hook will fire during app initialization const result = await rig.run({ args: 'Who are you?' }); // Check if systemMessage was displayed (in stderr, which rig.run captures) expect(result).toContain('Context injected via SessionStart hook'); // Check if additionalContext influenced the model response // Note: We use fake responses, but the rig records interactions. // If we are using fake responses, the model won't actually respond unless we provide a fake response for the injected context. // But the test rig setup uses 'hooks-system.session-startup.responses'. // If I'm adding a new test, I might need to generate new fake responses or expect the context to be sent to the model (verify API logs). // Verify hook executed const hookLogs = rig.readHookLogs(); const sessionStartLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'SessionStart', ); expect(sessionStartLog).toBeDefined(); // Verify the API request contained the injected context // rig.readAllApiRequest() gives us telemetry on API requests. const apiRequests = rig.readAllApiRequest(); // We expect at least one API request expect(apiRequests.length).toBeGreaterThan(0); // The injected context should be in the request text // For non-interactive mode, I prepended it to input: "context\n\ninput" // The telemetry `request_text` should contain it. const requestText = apiRequests[0].attributes?.request_text || ''; expect(requestText).toContain('protocol droid'); }); it('should fire SessionStart hook and display systemMessage in interactive mode', async () => { // Create hook script that outputs JSON with systemMessage and additionalContext const hookScript = `const fs = require('fs'); console.log(JSON.stringify({ decision: 'allow', systemMessage: 'Interactive Session Start Message', hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'The user is a Jedi Master.' } }));`; rig.setup( 'should fire SessionStart hook and display systemMessage in interactive mode', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), }, ); const scriptPath = rig.createScript( 'session_start_interactive_hook.cjs', hookScript, ); rig.setup( 'should fire SessionStart hook and display systemMessage in interactive mode', { settings: { hooksConfig: { enabled: true, }, hooks: { SessionStart: [ { matcher: 'startup', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }, ); const run = await rig.runInteractive(); // Verify systemMessage is displayed await run.expectText('Interactive Session Start Message', 10000); // Send a prompt to establish a session and trigger an API call await run.sendKeys('Hello'); await run.type('\r'); // Wait for response to ensure API call happened await run.expectText('Hello', 15000); // Wait for telemetry to be written to disk await rig.waitForTelemetryReady(); // Verify the API request contained the injected context // We may need to poll for API requests as they are written asynchronously const pollResult = await poll( () => { const apiRequests = rig.readAllApiRequest(); return apiRequests.length > 0; }, 15000, 500, ); expect(pollResult).toBe(true); const apiRequests = rig.readAllApiRequest(); // The injected context should be in the request_text of the API request const requestText = apiRequests[0].attributes?.request_text || ''; expect(requestText).toContain('Jedi Master'); }); it('should fire SessionEnd and SessionStart hooks on /clear command', async () => { rig.setup( 'should fire SessionEnd and SessionStart hooks on /clear command', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-clear.responses', ), }, ); // Create script files for hooks const endScriptPath = rig.createScript( 'session_end_clear.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session ending due to clear'}));", ); const startScriptPath = rig.createScript( 'session_start_clear.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}));", ); const sessionEndCommand = `node "${endScriptPath}"`; const sessionStartCommand = `node "${startScriptPath}"`; rig.setup( 'should fire SessionEnd and SessionStart hooks on /clear command', { settings: { hooksConfig: { enabled: true, }, hooks: { SessionEnd: [ { matcher: '*', sequential: true, hooks: [ { type: 'command', command: normalizePath(sessionEndCommand), timeout: 5000, }, ], }, ], SessionStart: [ { matcher: '*', sequential: true, hooks: [ { type: 'command', command: normalizePath(sessionStartCommand), timeout: 5000, }, ], }, ], }, }, }, ); const run = await rig.runInteractive(); // Send an initial prompt to establish a session await run.sendKeys('Say hello'); await run.type('\r'); // Wait for the response await run.expectText('Hello', 10000); // Execute /clear command multiple times to generate more hook events // This makes the test more robust by creating multiple start/stop cycles const numClears = 3; for (let i = 0; i < numClears; i++) { await run.sendKeys('/clear'); await run.type('\r'); // Wait a bit for clear to complete await new Promise((resolve) => setTimeout(resolve, 2000)); // Send a prompt to establish an active session before next clear await run.sendKeys('Say hello'); await run.type('\r'); // Wait for response await run.expectText('Hello', 10000); } // Wait for all clears to complete // BatchLogRecordProcessor exports telemetry every 10 seconds by default // Use generous wait time across all platforms (CI, Docker, Mac, Linux) await new Promise((resolve) => setTimeout(resolve, 15000)); // Wait for telemetry to be written to disk await rig.waitForTelemetryReady(); // Wait for hook telemetry events to be flushed to disk // In interactive mode, telemetry may be buffered, so we need to poll for the events // We execute multiple clears to generate more hook events (total: 1 + numClears * 2) // But we only require >= 1 hooks to pass, making the test more permissive const expectedMinHooks = 1; // SessionStart (startup), SessionEnd (clear), SessionStart (clear) const pollResult = await poll( () => { const hookLogs = rig.readHookLogs(); return hookLogs.length >= expectedMinHooks; }, 90000, // 90 second timeout for all platforms 1000, // check every 1s to reduce I/O overhead ); // If polling failed, log diagnostic info if (!pollResult) { const hookLogs = rig.readHookLogs(); const hookEvents = hookLogs.map((log) => log.hookCall.hook_event_name); console.error( `Polling timeout after 90000ms: Expected >= ${expectedMinHooks} hooks, got ${hookLogs.length}`, ); console.error( 'Hooks found:', hookEvents.length > 0 ? hookEvents.join(', ') : 'NONE', ); console.error('Full hook logs:', JSON.stringify(hookLogs, null, 2)); } // Verify hooks executed const hookLogs = rig.readHookLogs(); // Diagnostic: Log which hooks we actually got const hookEvents = hookLogs.map((log) => log.hookCall.hook_event_name); if (hookLogs.length < expectedMinHooks) { console.error( `TEST FAILURE: Expected >= ${expectedMinHooks} hooks, got ${hookLogs.length}: [${hookEvents.length > 0 ? hookEvents.join(', ') : 'NONE'}]`, ); } expect(hookLogs.length).toBeGreaterThanOrEqual(expectedMinHooks); // Find SessionEnd hook log const sessionEndLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'SessionEnd' && log.hookCall.hook_name === normalizePath(sessionEndCommand), ); // Because the flakiness of the test, we relax this check // expect(sessionEndLog).toBeDefined(); if (sessionEndLog) { expect(sessionEndLog.hookCall.exit_code).toBe(0); expect(sessionEndLog.hookCall.stdout).toContain( 'Session ending due to clear', ); // Verify hook input contains reason const hookInputStr = typeof sessionEndLog.hookCall.hook_input === 'string' ? sessionEndLog.hookCall.hook_input : JSON.stringify(sessionEndLog.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; expect(hookInput['reason']).toBe('clear'); } // Find SessionStart hook log after clear const sessionStartAfterClearLogs = hookLogs.filter( (log) => log.hookCall.hook_event_name === 'SessionStart' && log.hookCall.hook_name === normalizePath(sessionStartCommand), ); // Should have at least one SessionStart from after clear // Because the flakiness of the test, we relax this check // expect(sessionStartAfterClearLogs.length).toBeGreaterThanOrEqual(1); const sessionStartLog = sessionStartAfterClearLogs.find((log) => { const hookInputStr = typeof log.hookCall.hook_input === 'string' ? log.hookCall.hook_input : JSON.stringify(log.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; return hookInput['source'] === 'clear'; }); // Because the flakiness of the test, we relax this check // expect(sessionStartLog).toBeDefined(); if (sessionStartLog) { expect(sessionStartLog.hookCall.exit_code).toBe(0); expect(sessionStartLog.hookCall.stdout).toContain( 'Session starting after clear', ); } }); }); describe('Compression Hooks', () => { it('should fire PreCompress hook on automatic compression', async () => { rig.setup('should fire PreCompress hook on automatic compression', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.compress-auto.responses', ), }); // Create script file for hook const scriptPath = rig.createScript( 'pre_compress_hook.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}));", ); const preCompressCommand = `node "${scriptPath}"`; rig.setup('should fire PreCompress hook on automatic compression', { settings: { hooksConfig: { enabled: true, }, hooks: { PreCompress: [ { matcher: 'auto', sequential: true, hooks: [ { type: 'command', command: normalizePath(preCompressCommand), timeout: 5000, }, ], }, ], }, // Configure automatic compression with a very low threshold // This will trigger auto-compression after the first response contextCompression: { // enabled: true, targetTokenCount: 10, // Very low threshold to trigger compression }, }, }); // Run a simple query that will trigger automatic compression await rig.run({ args: 'Say hello in exactly 5 words' }); // Verify hook executed with correct parameters const hookLogs = rig.readHookLogs(); const preCompressLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'PreCompress', ); expect(preCompressLog).toBeDefined(); if (preCompressLog) { expect(preCompressLog.hookCall.hook_name).toBe( normalizePath(preCompressCommand), ); expect(preCompressLog.hookCall.exit_code).toBe(0); expect(preCompressLog.hookCall.hook_input).toBeDefined(); // hook_input is a string that needs to be parsed const hookInputStr = typeof preCompressLog.hookCall.hook_input === 'string' ? preCompressLog.hookCall.hook_input : JSON.stringify(preCompressLog.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; expect(hookInput['trigger']).toBe('auto'); expect(preCompressLog.hookCall.stdout).toContain( 'PreCompress hook executed for automatic compression', ); } }); }); describe('SessionEnd on Exit', () => { it('should fire SessionEnd hook on graceful exit in non-interactive mode', async () => { rig.setup('should fire SessionEnd hook on graceful exit', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), }); // Create script file for hook const scriptPath = rig.createScript( 'session_end_exit.cjs', "console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}));", ); const sessionEndCommand = `node "${scriptPath}"`; rig.setup('should fire SessionEnd hook on graceful exit', { settings: { hooksConfig: { enabled: true, }, hooks: { SessionEnd: [ { matcher: 'exit', sequential: true, hooks: [ { type: 'command', command: normalizePath(sessionEndCommand), timeout: 5000, }, ], }, ], }, }, }); // Run in non-interactive mode with a simple prompt await rig.run({ args: 'Hello' }); // The process should exit gracefully, firing the SessionEnd hook // Wait for telemetry to be written to disk await rig.waitForTelemetryReady(); // Poll for the hook log to appear const isCI = process.env['CI'] === 'true'; const pollTimeout = isCI ? 30000 : 10000; const pollResult = await poll( () => { const hookLogs = rig.readHookLogs(); return hookLogs.some( (log) => log.hookCall.hook_event_name === 'SessionEnd', ); }, pollTimeout, 200, ); if (!pollResult) { const hookLogs = rig.readHookLogs(); console.error( 'Polling timeout: Expected SessionEnd hook, got:', JSON.stringify(hookLogs, null, 2), ); } expect(pollResult).toBe(true); const hookLogs = rig.readHookLogs(); const sessionEndLog = hookLogs.find( (log) => log.hookCall.hook_event_name === 'SessionEnd', ); expect(sessionEndLog).toBeDefined(); if (sessionEndLog) { expect(sessionEndLog.hookCall.hook_name).toBe( normalizePath(sessionEndCommand), ); expect(sessionEndLog.hookCall.exit_code).toBe(0); expect(sessionEndLog.hookCall.hook_input).toBeDefined(); const hookInputStr = typeof sessionEndLog.hookCall.hook_input === 'string' ? sessionEndLog.hookCall.hook_input : JSON.stringify(sessionEndLog.hookCall.hook_input); const hookInput = JSON.parse(hookInputStr) as Record; expect(hookInput['reason']).toBe('exit'); expect(sessionEndLog.hookCall.stdout).toContain( 'SessionEnd hook executed', ); } }); }); describe('Hook Disabling', () => { it('should not execute hooks disabled in settings file', async () => { const enabledMsg = 'EXECUTION_ALLOWED_BY_HOOK_A'; const disabledMsg = 'EXECUTION_BLOCKED_BY_HOOK_B'; const enabledJson = JSON.stringify({ decision: 'allow', systemMessage: enabledMsg, }); const disabledJson = JSON.stringify({ decision: 'block', reason: disabledMsg, }); const enabledScript = `console.log(JSON.stringify(${enabledJson}));`; const disabledScript = `console.log(JSON.stringify(${disabledJson}));`; const enabledFilename = 'enabled_hook.js'; const disabledFilename = 'disabled_hook.js'; const enabledCmd = `node ${enabledFilename}`; const disabledCmd = `node ${disabledFilename}`; // 3. Final setup with full settings rig.setup('Hook Disabling Settings', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.disabled-via-settings.responses', ), settings: { hooksConfig: { enabled: true, disabled: ['hook-b'], }, hooks: { BeforeTool: [ { hooks: [ { type: 'command', name: 'hook-a', command: enabledCmd, timeout: 60000, }, { type: 'command', name: 'hook-b', command: disabledCmd, timeout: 60000, }, ], }, ], }, }, }); rig.createScript(enabledFilename, enabledScript); rig.createScript(disabledFilename, disabledScript); await rig.run({ args: 'Create a file called disabled-test.txt with content "test"', }); // Tool should execute (enabled hook allows it) const foundWriteFile = await rig.waitForToolCall('write_file'); expect(foundWriteFile).toBeTruthy(); // Check hook telemetry - only enabled hook should have executed const hookLogs = rig.readHookLogs(); const enabledHookLog = hookLogs.find((log) => JSON.stringify(log.hookCall.hook_output).includes(enabledMsg), ); const disabledHookLog = hookLogs.find((log) => JSON.stringify(log.hookCall.hook_output).includes(disabledMsg), ); expect(enabledHookLog).toBeDefined(); expect(disabledHookLog).toBeUndefined(); }); it('should respect disabled hooks across multiple operations', async () => { const activeMsg = 'MULTIPLE_OPS_ENABLED_HOOK'; const disabledMsg = 'MULTIPLE_OPS_DISABLED_HOOK'; const activeJson = JSON.stringify({ decision: 'allow', systemMessage: activeMsg, }); const disabledJson = JSON.stringify({ decision: 'block', reason: disabledMsg, }); const activeScript = `console.log(JSON.stringify(${activeJson}));`; const disabledScript = `console.log(JSON.stringify(${disabledJson}));`; const activeFilename = 'active_hook.js'; const disabledFilename = 'disabled_hook.js'; const activeCmd = `node ${activeFilename}`; const disabledCmd = `node ${disabledFilename}`; // 3. Final setup with full settings rig.setup('Hook Disabling Multiple Ops', { settings: { hooksConfig: { enabled: true, disabled: ['multi-hook-disabled'], }, hooks: { BeforeTool: [ { hooks: [ { type: 'command', name: 'multi-hook-active', command: activeCmd, timeout: 60000, }, { type: 'command', name: 'multi-hook-disabled', command: disabledCmd, timeout: 60000, }, ], }, ], }, }, }); rig.createScript(activeFilename, activeScript); rig.createScript(disabledFilename, disabledScript); // First run - only active hook should execute await rig.run({ args: 'Create a file called first-run.txt with "test1"', }); // Tool should execute (active hook allows it) const foundWriteFile1 = await rig.waitForToolCall('write_file'); expect(foundWriteFile1).toBeTruthy(); // Check hook telemetry - only active hook should have executed const hookLogs1 = rig.readHookLogs(); const activeHookLog1 = hookLogs1.find((log) => JSON.stringify(log.hookCall.hook_output).includes(activeMsg), ); const disabledHookLog1 = hookLogs1.find((log) => JSON.stringify(log.hookCall.hook_output).includes(disabledMsg), ); expect(activeHookLog1).toBeDefined(); expect(disabledHookLog1).toBeUndefined(); // Second run - verify disabled hook stays disabled await rig.run({ args: 'Create a file called second-run.txt with "test2"', }); const foundWriteFile2 = await rig.waitForToolCall('write_file'); expect(foundWriteFile2).toBeTruthy(); // Verify disabled hook still hasn't executed const hookLogs2 = rig.readHookLogs(); const disabledHookLog2 = hookLogs2.find((log) => JSON.stringify(log.hookCall.hook_output).includes(disabledMsg), ); expect(disabledHookLog2).toBeUndefined(); }); }); describe('BeforeTool Hooks - Input Override', () => { it('should override tool input parameters via BeforeTool hook', async () => { // 1. First setup to get the test directory and prepare the hook script rig.setup('should override tool input parameters via BeforeTool hook'); // Create a hook script that overrides the tool input const hookOutput = { decision: 'allow', hookSpecificOutput: { hookEventName: 'BeforeTool', tool_input: { file_path: 'modified.txt', content: 'modified content', }, }, }; const hookScript = `process.stdout.write(JSON.stringify(${JSON.stringify( hookOutput, )}));`; const scriptPath = rig.createScript('input_override_hook.js', hookScript); // 2. Full setup with settings and fake responses rig.setup('should override tool input parameters via BeforeTool hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.input-modification.responses', ), settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); // Run the agent. The fake response will attempt to call write_file with // file_path="original.txt" and content="original content" await rig.run({ args: 'Create a file called original.txt with content "original content"', }); // 1. Verify that 'modified.txt' was created with 'modified content' (Override successful) const modifiedContent = rig.readFile('modified.txt'); expect(modifiedContent).toBe('modified content'); // 2. Verify that 'original.txt' was NOT created (Override replaced original) let originalExists = false; try { rig.readFile('original.txt'); originalExists = true; } catch { originalExists = false; } expect(originalExists).toBe(false); // 3. Verify hook telemetry const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); const hookLogs = rig.readHookLogs(); expect(hookLogs.length).toBe(1); expect(hookLogs[0].hookCall.hook_name).toContain( 'input_override_hook.js', ); // 4. Verify that the agent didn't try to work-around the hook input change const toolLogs = rig.readToolLogs(); expect(toolLogs.length).toBe(1); expect(toolLogs[0].toolRequest.name).toBe('write_file'); expect(JSON.parse(toolLogs[0].toolRequest.args).file_path).toBe( 'modified.txt', ); }); }); describe('BeforeTool Hooks - Stop Execution', () => { it('should stop agent execution via BeforeTool hook', async () => { // Create a hook script that stops execution const hookOutput = { continue: false, reason: 'Emergency Stop triggered by hook', hookSpecificOutput: { hookEventName: 'BeforeTool', }, }; const hookScript = `console.log(JSON.stringify(${JSON.stringify( hookOutput, )}));`; rig.setup('should stop agent execution via BeforeTool hook'); const scriptPath = rig.createScript( 'before_tool_stop_hook.js', hookScript, ); rig.setup('should stop agent execution via BeforeTool hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-tool-stop.responses', ), settings: { hooksConfig: { enabled: true, }, hooks: { BeforeTool: [ { matcher: 'write_file', sequential: true, hooks: [ { type: 'command', command: normalizePath(`node "${scriptPath}"`), timeout: 5000, }, ], }, ], }, }, }); const result = await rig.run({ args: 'Use write_file to create test.txt', }); // The hook should have stopped execution message (returned from tool) expect(result).toContain( 'Agent execution stopped: Emergency Stop triggered by hook', ); // Tool should NOT be called successfully (it was blocked/stopped) const toolLogs = rig.readToolLogs(); const writeFileCalls = toolLogs.filter( (t) => t.toolRequest.name === 'write_file' && t.toolRequest.success === true, ); expect(writeFileCalls).toHaveLength(0); }); }); }); ================================================ FILE: integration-tests/json-output.error.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Investigating File Access**\n\nI'm currently focused on the challenge of reading a file. The path provided is `/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt`, and I'm anticipating an error. It's safe to assume the file doesn't exist, which I intend to handle by responding with \"File not found\" as instructed.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12418,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":115}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Analyzing Error Handling**\n\nI've attempted to read the specified file, expecting an error due to the \"nonexistent\" path. My plan is to catch the error thrown by the `read_file` tool. Upon receiving this error, I'll promptly return \"File not found.\" This is in line with the initial instructions and ensures appropriate error management for the user's intended functionality. I'm now testing the error response.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12467,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb0c450IIdZRHl1vvjWDAl9oKa7s5sfFgrTnU0w3qBQwKYgHR7YpvPjZlXSHaJNYgX8IuCvxfyyeACX4NZ8u+u3Z1kqQbgOMpiH6aPYCK9lPyhVftPDBF2m3j7Y2AShwXSpB+9+UB3zphOKCvq6i0ZLvK6QzVynZ1fySQacyjEBD+U6y5CpoBAdHtim9D6oskRu7f3x8rp56h24i6dwb1hzlyqLGl3A5Hsh/fGYjBCxR+Vs+U5Sb7LunmFMKxmO1fktz0x06FUiyaWgAaXl4E4FyLTKs9BbPdgo58uAhaI3vTQybeGZQzkRi6n+ywCniVKaTUd07EHYWdvLiB6x5zFKVOiTmh8PC6I4vNOkJIzFXRfR5aM5QFjEQY8R88HBH+ugraAQHR7YpvuNUG8Ttbzg+kq+kJYMNGDG4zVLPxFBbJZYU5zGLFLrNeEpp9HJ+1N1Pdts17J5pzoefKd2U9H5muPxoeW1CCpFqqKfYZNyoChrQnb4CgWoJ92pcb2UAZbDQ6H4rTyXWdXaAEP1i8rOmdWzI5GFPyxCwBKAbFhfMJOo7P4va5H13XTlLgWAgXlx3n24ookkD/DOL9ro/F6pmV6tzf/j7RVl0V5yozngnrFl4dFsQPp1nDQcFkbRIKHD30byjLGh6Vd6A2lrB477qUaCRfPXtlEgKgsOSYCuABAdHtim+PJPZV3QuQoJ0Qokr+vcdW1W2qzC4Vvt4QRJXyp6+eIS6iNjOisk4jfWLHmLTa11KrUSQsFJnGwsFhe1foOqfO4y46ROBPeSdyuc0gDzSCZPowiovGBwoZd+MbPTTVyExWBo6StVk+xWLj+sUqa8VDof45AOOK9kdzcLQPa2SXIeK6SYLS1zVTrbzGHQDZPv9UC99lmKxPFqAcuwpe9CQre9J3GcZSwd5Mfs/UkHbWui10a8jga4Ck7umjKSDFq/VWiRUp/jh+EN4xeLTG/UQhgH49Gw+Bi1hN1uYKIgHR7YpvS0j1oO2HV/HeK3YxSku9CLQ3OSJGhoAKiu8UGYE=","functionCall":{"name":"read_file","args":{"file_path":"/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12303,"candidatesTokenCount":58,"totalTokenCount":12525,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File not found"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12588,"candidatesTokenCount":3,"totalTokenCount":12591,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12588}]}}]} ================================================ FILE: integration-tests/json-output.france.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The capital of France is Paris."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":7,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} ================================================ FILE: integration-tests/json-output.session-id.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":9,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} ================================================ FILE: integration-tests/json-output.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; import { ExitCodes } from '@google/gemini-cli-core/src/index.js'; describe('JSON output', () => { let rig: TestRig; beforeEach(async () => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should return a valid JSON with response and stats', async () => { await rig.setup('json-output-france', { fakeResponsesPath: join( import.meta.dirname, 'json-output.france.responses', ), }); const result = await rig.run({ args: ['What is the capital of France?', '--output-format', 'json'], }); const parsed = JSON.parse(result); expect(parsed).toHaveProperty('response'); expect(typeof parsed.response).toBe('string'); expect(parsed.response.toLowerCase()).toContain('paris'); expect(parsed).toHaveProperty('stats'); expect(typeof parsed.stats).toBe('object'); }); it('should return a valid JSON with a session ID', async () => { await rig.setup('json-output-session-id', { fakeResponsesPath: join( import.meta.dirname, 'json-output.session-id.responses', ), }); const result = await rig.run({ args: ['Hello', '--output-format', 'json'], }); const parsed = JSON.parse(result); expect(parsed).toHaveProperty('session_id'); expect(typeof parsed.session_id).toBe('string'); expect(parsed.session_id).not.toBe(''); }); it('should return a JSON error for sd auth mismatch before running', async () => { await rig.setup('json-output-auth-mismatch', { settings: { security: { auth: { enforcedType: 'gemini-api-key', selectedType: '' }, }, }, }); let thrown: Error | undefined; try { await rig.run({ args: ['Hello', '--output-format', 'json'], env: { GOOGLE_GENAI_USE_GCA: 'true' }, }); expect.fail('Expected process to exit with error'); } catch (e) { thrown = e as Error; } expect(thrown).toBeDefined(); const message = (thrown as Error).message; // Use a regex to find the first complete JSON object in the string // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid // matching random error objects printed to stderr (like ENOENT). const jsonMatch = message.match(/{\s*"[\s\S]*}/); // Fail if no JSON-like text was found expect( jsonMatch, 'Expected to find a JSON object in the error output', ).toBeTruthy(); let payload; try { // Parse the matched JSON string payload = JSON.parse(jsonMatch![0]); } catch (parseError) { console.error('Failed to parse the following JSON:', jsonMatch![0]); throw new Error( `Test failed: Could not parse JSON from error message. Details: ${parseError}`, ); } expect(payload.error).toBeDefined(); expect(payload.error.type).toBe('Error'); expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); expect(payload.error.message).toContain( "enforced authentication type is 'gemini-api-key'", ); expect(payload.error.message).toContain("current type is 'oauth-personal'"); expect(payload).toHaveProperty('session_id'); expect(typeof payload.session_id).toBe('string'); expect(payload.session_id).not.toBe(''); }); it('should not exit on tool errors and allow model to self-correct in JSON mode', async () => { await rig.setup('json-output-error', { fakeResponsesPath: join( import.meta.dirname, 'json-output.error.responses', ), }); const result = await rig.run({ args: [ `Read the contents of ${rig.testDir}/path/to/nonexistent/file.txt and tell me what it says. ` + 'On error, respond to the user with exactly the text "File not found".', '--output-format', 'json', ], }); const parsed = JSON.parse(result); // The response should contain an actual response from the model, // not a fatal error that caused the CLI to exit expect(parsed).toHaveProperty('response'); expect(typeof parsed.response).toBe('string'); // The model should acknowledge the error in its response with exactly the // text "File not found" based on the instruction above, but we also match // some other forms. If you get flakes for this test please file an issue to // come up with a more robust solution. expect(parsed.response.toLowerCase()).toMatch( /cannot|does not exist|doesn't exist|not found|unable to|error|couldn't/, ); // Stats should be present, indicating the session completed normally. expect(parsed).toHaveProperty('stats'); // Should see one failed tool call in the stats. expect(parsed.stats).toHaveProperty('tools'); expect(parsed.stats.tools.totalCalls).toBe(1); expect(parsed.stats.tools.totalFail).toBe(1); expect(parsed.stats.tools.totalSuccess).toBe(0); // Should NOT have an error field at the top level expect(parsed.error).toBeUndefined(); expect(parsed).toHaveProperty('session_id'); expect(typeof parsed.session_id).toBe('string'); expect(parsed.session_id).not.toBe(''); }); }); ================================================ FILE: integration-tests/list_directory.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig, poll, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; describe('list_directory', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to list a directory', async () => { await rig.setup('should be able to list a directory', { settings: { tools: { core: ['list_directory'] } }, }); rig.createFile('file1.txt', 'file 1 content'); rig.mkdir('subdir'); rig.sync(); // Poll for filesystem changes to propagate in containers await poll( () => { // Check if the files exist in the test directory const file1Path = join(rig.testDir!, 'file1.txt'); const subdirPath = join(rig.testDir!, 'subdir'); return existsSync(file1Path) && existsSync(subdirPath); }, 1000, // 1 second max wait 50, // check every 50ms ); const prompt = `Can you list the files in the current directory.`; const result = await rig.run({ args: prompt }); try { await rig.expectToolCallSuccess(['list_directory']); } catch (e) { // Add debugging information if (!result.includes('file1.txt') || !result.includes('subdir')) { const allTools = printDebugInfo(rig, result, { 'Found tool call': false, 'Contains file1.txt': result.includes('file1.txt'), 'Contains subdir': result.includes('subdir'), }); console.error( 'List directory calls:', allTools .filter((t) => t.toolRequest.name === 'list_directory') .map((t) => t.toolRequest.args), ); } throw e; } assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: ['file1.txt', 'subdir'], testName: 'List directory test', }); }); }); ================================================ FILE: integration-tests/mcp_server_cyclic_schema.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * This test verifies we can provide MCP tools with recursive input schemas * (in JSON, using the $ref keyword) and both the GenAI SDK and the Gemini * API calls succeed. Note that prior to * https://github.com/googleapis/js-genai/commit/36f6350705ecafc47eaea3f3eecbcc69512edab7#diff-fdde9372aec859322b7c5a5efe467e0ad25a57210c7229724586ee90ea4f5a30 * the Gemini API call would fail for such tools because the schema was * passed not as a JSON string but using the Gemini API's tool parameter * schema object which has stricter typing and recursion restrictions. * If this test fails, it's likely because either the GenAI SDK or Gemini API * has become more restrictive about the type of tool parameter schemas that * are accepted. If this occurs: Gemini CLI previously attempted to detect * such tools and proactively remove them from the set of tools provided in * the Gemini API call (as FunctionDeclaration objects). It may be appropriate * to resurrect that behavior but note that it's difficult to keep the * GCLI filters in sync with the Gemini API restrictions and behavior. */ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, it, afterEach, beforeEach } from 'vitest'; import { TestRig } from './test-helper.js'; // Create a minimal MCP server that doesn't require external dependencies // This implements the MCP protocol directly using Node.js built-ins const serverScript = `#!/usr/bin/env node /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const readline = require('readline'); const fs = require('fs'); // Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; function debug(msg) { if (debugEnabled) { fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); } } debug('MCP server starting...'); // Simple JSON-RPC implementation for MCP class SimpleJSONRPC { constructor() { this.handlers = new Map(); this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); this.rl.on('line', (line) => { debug(\`Received line: \${line}\`); try { const message = JSON.parse(line); debug(\`Parsed message: \${JSON.stringify(message)}\`); this.handleMessage(message); } catch (e) { debug(\`Parse error: \${e.message}\`); } }); } send(message) { const msgStr = JSON.stringify(message); debug(\`Sending message: \${msgStr}\`); process.stdout.write(msgStr + '\\n'); } async handleMessage(message) { if (message.method && this.handlers.has(message.method)) { try { const result = await this.handlers.get(message.method)(message.params || {}); if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, result }); } } catch (error) { if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } }); } } } else if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }); } } on(method, handler) { this.handlers.set(method, handler); } } // Create MCP server const rpc = new SimpleJSONRPC(); // Handle initialize rpc.on('initialize', async (params) => { debug('Handling initialize request'); return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'cyclic-schema-server', version: '1.0.0' } }; }); // Handle tools/list rpc.on('tools/list', async () => { debug('Handling tools/list request'); return { tools: [{ name: 'tool_with_cyclic_schema', inputSchema: { type: 'object', properties: { data: { type: 'array', items: { type: 'object', properties: { child: { $ref: '#/properties/data/items' }, }, }, }, }, } }] }; }); // Send initialization notification rpc.send({ jsonrpc: '2.0', method: 'initialized' }); `; describe('mcp server with cyclic tool schema is detected', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('mcp tool list should include tool with cyclic tool schema', async () => { // Setup test directory with MCP server configuration await rig.setup('cyclic-schema-mcp-server', { settings: { mcpServers: { 'cyclic-schema-server': { command: 'node', args: ['mcp-server.cjs'], }, }, }, }); // Create server script in the test directory const testServerPath = join(rig.testDir!, 'mcp-server.cjs'); writeFileSync(testServerPath, serverScript); // Make the script executable (though running with 'node' should work anyway) if (process.platform !== 'win32') { const { chmodSync } = await import('node:fs'); chmodSync(testServerPath, 0o755); } const run = await rig.runInteractive(); await run.type('/mcp list'); await run.type('\r'); await run.expectText('tool_with_cyclic_schema'); }); }); ================================================ FILE: integration-tests/mixed-input-crash.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; describe('mixed input crash prevention', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should not crash when using mixed prompt inputs', async () => { rig.setup('should not crash when using mixed prompt inputs'); // Test: echo "say '1'." | gemini --prompt-interactive="say '2'." say '3'. const stdinContent = "say '1'."; try { await rig.run({ args: ['--prompt-interactive', "say '2'.", "say '3'."], stdin: stdinContent, }); throw new Error('Expected the command to fail, but it succeeded'); } catch (error: unknown) { expect(error).toBeInstanceOf(Error); const err = error as Error; expect(err.message).toContain('Process exited with code 42'); expect(err.message).toContain( '--prompt-interactive flag cannot be used when input is piped', ); expect(err.message).not.toContain('setRawMode is not a function'); expect(err.message).not.toContain('unexpected critical error'); } const lastRequest = rig.readLastApiRequest(); expect(lastRequest).toBeNull(); }); it('should provide clear error message for mixed input', async () => { rig.setup('should provide clear error message for mixed input'); try { await rig.run({ args: ['--prompt-interactive', 'test prompt'], stdin: 'test input', }); throw new Error('Expected the command to fail, but it succeeded'); } catch (error: unknown) { expect(error).toBeInstanceOf(Error); const err = error as Error; expect(err.message).toContain( '--prompt-interactive flag cannot be used when input is piped', ); } }); }); ================================================ FILE: integration-tests/parallel-tools.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/parallel-tools.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; import fs from 'node:fs'; describe('Parallel Tool Execution Integration', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should execute [read, read, write, read, read] in correct waves with user approval', async () => { rig.setup('parallel-wave-execution', { fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'), settings: { tools: { core: ['read_file', 'write_file'], approval: 'ASK', // Disable YOLO mode to show permission prompts confirmationRequired: ['write_file'], }, }, }); rig.createFile('file1.txt', 'c1'); rig.createFile('file2.txt', 'c2'); rig.createFile('file3.txt', 'c3'); rig.createFile('file4.txt', 'c4'); rig.sync(); const run = await rig.runInteractive({ approvalMode: 'default' }); // 1. Trigger the wave await run.type('ok'); await run.type('\r'); // 3. Wait for the write_file prompt. await run.expectText('Allow', 5000); // 4. Press Enter to approve the write_file. await run.type('y'); await run.type('\r'); // 5. Wait for the final model response await run.expectText('All waves completed successfully.', 5000); // Verify all tool calls were made and succeeded in the logs await rig.expectToolCallSuccess(['write_file']); const toolLogs = rig.readToolLogs(); const readFiles = toolLogs.filter( (l) => l.toolRequest.name === 'read_file', ); const writeFiles = toolLogs.filter( (l) => l.toolRequest.name === 'write_file', ); expect(readFiles.length).toBe(4); expect(writeFiles.length).toBe(1); expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true); // Check that output.txt was actually written expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe( 'wave2', ); }); }); ================================================ FILE: integration-tests/plan-mode.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should allow read-only tools but deny write tools in plan mode', async () => { await rig.setup( 'should allow read-only tools but deny write tools in plan mode', { settings: { experimental: { plan: true }, tools: { core: [ 'run_shell_command', 'list_directory', 'write_file', 'read_file', ], }, }, }, ); // We use a prompt that asks for both a read-only action and a write action. // "List files" (read-only) followed by "touch denied.txt" (write). const result = await rig.run({ approvalMode: 'plan', stdin: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.', }); const lsCallFound = await rig.waitForToolCall('list_directory'); expect(lsCallFound, 'Expected list_directory to be called').toBe(true); const shellCallFound = await rig.waitForToolCall('run_shell_command'); expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false); const toolLogs = rig.readToolLogs(); const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory'); expect( toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'), ).toBeUndefined(); expect(lsLog?.toolRequest.success).toBe(true); checkModelOutputContent(result, { expectedContent: ['Plan Mode', 'read-only'], testName: 'Plan Mode restrictions test', }); }); it('should allow write_file to the plans directory in plan mode', async () => { const plansDir = '.gemini/tmp/foo/123/plans'; const testName = 'should allow write_file to the plans directory in plan mode'; await rig.setup(testName, { settings: { experimental: { plan: true }, tools: { core: ['write_file', 'read_file', 'list_directory'], }, general: { defaultApprovalMode: 'plan', plan: { directory: plansDir, }, }, }, }); // Disable the interactive terminal setup prompt in tests writeFileSync( join(rig.homeDir!, GEMINI_DIR, 'state.json'), JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); const run = await rig.runInteractive({ approvalMode: 'plan', }); await run.type('Create a file called plan.md in the plans directory.'); await run.type('\r'); await rig.expectToolCallSuccess(['write_file'], 30000, (args) => args.includes('plan.md'), ); const toolLogs = rig.readToolLogs(); const planWrite = toolLogs.find( (l) => l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); expect(planWrite?.toolRequest.success).toBe(true); }); it('should deny write_file to non-plans directory in plan mode', async () => { const plansDir = '.gemini/tmp/foo/123/plans'; const testName = 'should deny write_file to non-plans directory in plan mode'; await rig.setup(testName, { settings: { experimental: { plan: true }, tools: { core: ['write_file', 'read_file', 'list_directory'], }, general: { defaultApprovalMode: 'plan', plan: { directory: plansDir, }, }, }, }); // Disable the interactive terminal setup prompt in tests writeFileSync( join(rig.homeDir!, GEMINI_DIR, 'state.json'), JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); const run = await rig.runInteractive({ approvalMode: 'plan', }); await run.type('Create a file called hello.txt in the current directory.'); await run.type('\r'); const toolLogs = rig.readToolLogs(); const writeLog = toolLogs.find( (l) => l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('hello.txt'), ); // In Plan Mode, writes outside the plans directory should be blocked. // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. if (writeLog) { expect(writeLog.toolRequest.success).toBe(false); } }); it('should be able to enter plan mode from default mode', async () => { await rig.setup('should be able to enter plan mode from default mode', { settings: { experimental: { plan: true }, tools: { core: ['enter_plan_mode'], allowed: ['enter_plan_mode'], }, }, }); // Disable the interactive terminal setup prompt in tests writeFileSync( join(rig.homeDir!, GEMINI_DIR, 'state.json'), JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); // Start in default mode and ask to enter plan mode. await rig.run({ approvalMode: 'default', stdin: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.', }); const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode'); expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe( true, ); const toolLogs = rig.readToolLogs(); const enterLog = toolLogs.find( (l) => l.toolRequest.name === 'enter_plan_mode', ); expect(enterLog?.toolRequest.success).toBe(true); }); }); ================================================ FILE: integration-tests/policy-headless-readonly.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the content of the file to identify its"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":11,"totalTokenCount":8061,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":" language.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":14,"totalTokenCount":8064,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test.txt"}},"thoughtSignature":"EvkCCvYCAb4+9vt8mJ/o45uuuAJtfjaZ3YzkJzqXHZBttRE+Om0ahcr1S5RDFp50KpgHtJtbAH1pwEXampOnDV3WKiWwA+e3Jnyk4CNQegz7ZMKsl55Nem2XDViP8BZKnJVqGmSFuMoKJLFmbVIxKejtWcblfn3httbGsrUUNbHwdPjPHo1qY043lF63g0kWx4v68gPSsJpNhxLrSugKKjiyRFN+J0rOIBHI2S9MdZoHEKhJxvGMtXiJquxmhPmKcNEsn+hMdXAZB39hmrRrGRHDQPVYVPhfJthVc73ufzbn+5KGJpaMQyKY5hqrc2ea8MHz+z6BSx+tFz4NZBff1tJQOiUp09/QndxQRZHSQZr1ALGy0O1Qw4JqsX94x81IxtXqYkSRo3zgm2vl/xPMC5lKlnK5xoKJmoWaHkUNeXs/sopu3/Waf1a5Csoh9ImnKQsW0rJ6GRyDQvky1FwR6Aa98bgfNdcXOPHml/BtghaqRMXTiG6vaPJ8UFs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":81}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The language of the file is Latin."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8054,"candidatesTokenCount":8,"totalTokenCount":8078,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8054}],"thoughtsTokenCount":16}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EnIKcAG+Pvb7vnRBJVz3khx1oArQQqTNvXOXkliNQS7NvYw94dq5m+wGKRmSj3egO3GVp7pacnAtLn9NT1ABKBGpa7MpRhiAe3bbPZfkqOuveeyC19LKQ9fzasCywiYqg5k5qSxfjs5okk+O0NLOvTjN/tg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8135,"candidatesTokenCount":8,"totalTokenCount":8159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8135}],"thoughtsTokenCount":16}}]} ================================================ FILE: integration-tests/policy-headless-shell-allowed.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the requested"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":5,"totalTokenCount":8092,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":" shell command to verify the policy configuration.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":14,"totalTokenCount":8101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo POLICY_TEST_ECHO_COMMAND","description":"Echo the test string to verify policy settings."}},"thoughtSignature":"EpwFCpkFAb4+9vulXgVj96CAm2eMFbDEGHz9B37GwI8N1KOvu9AHwdYWiita7yS4RKAdeBui22B5320XBaxOtZGnMo2E9pG0Pcus2WsBiecRaHUTxTmhx1BvURevrs+5m4UJeLRGMfP94+ncha4DeIQod3PKBnK8xeIJTyZBFB7+hmHbHvem2VwZh/v14e4fXlpEkkdntJbzrA1nUdctIGdEmdm0sL8PaFnMqWLUnkZvGdfq7ctFt9EYk2HW2SrHVhk3HdsyWhoxNz2MU0sRWzAgiSQY/heSSAbU7Jdgg0RjwB9o3SkCIHxqnVpkH8PQsARwnah5I5s7pW6EHr3D4f1/UVl0n26hyI2xBqF/n4aZKhtX55U4h/DIhxooZa2znstt6BS8vRcdzflFrX7OV86WQxHE4JHjQecP2ciBRimm8pL3Od3pXnRcx32L8JbrWm6dPyWlo5h5uCRy0qXye2+3SuHs5wtxOjD9NETR4TwzqFe+m0zThpxsR1ZKQeKlO7lN/s3pWih/TjbZQEQs9xr72UnlE8ZtJ4bOKj8GNbemvsrbYAO98NzJwvdil0FhblaXmReP1uYjucmLC0jCJHShqNz2KzAkDTvKs4tmio13IuCRjTZ3E5owqCUn7djDqOSDwrg235RIVJkiDIaPlHemOR15lbVQD1VOzytzT8TZLEzTV750oyHq/IhLMQHYixO8jJ2GkVvUp7bxz9oQ4UeTqT5lTF4s40H2Rlkb6trF4hKXoFhzILy1aOJTC9W3fCoop7VJLIMNulgHLWxiq65Uas6sIep87yiD4xLfbGfMm6HS4JTRhPlfxeckn/SzUfu1afg1nAvW3vBlR/YNREf0N28/PnRC08VYqA3mqCRiyPqPWsf3a0jyio0dD9A="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":138}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"POLICY_TEST_"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":4,"totalTokenCount":8046,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":"ECHO_COMMAND"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":8,"totalTokenCount":8050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8180,"candidatesTokenCount":8,"totalTokenCount":8188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8180}]}}]} ================================================ FILE: integration-tests/policy-headless-shell-denied.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Assessing Command Execution**\n\nOkay, I'm currently assessing the feasibility of executing `echo POLICY_TEST_ECHO_COMMAND` using the `run_shell_command` function. Restrictions are being evaluated; the prompt is specifically geared towards a successful command output: \"POLICY_TEST_ECHO_COMMAND\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"totalTokenCount":7949,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}]}},{"candidates":[{"content":{"parts":[{"text":"I will execute the requested echo"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":6,"totalTokenCount":8161,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":" command to verify the policy."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":12,"totalTokenCount":8167,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"description":"Execute the echo command as requested.","command":"echo POLICY_TEST_ECHO_COMMAND"}},"thoughtSignature":"EvkGCvYGAb4+9vucYbmJ8DrNCca9c0C8o4qKQ6V2WnzmT4mbCw8V7s0+2I/PoxrgnsxZJIIRM8y5E4bW7Jbs46GjbJ2cefY9Q3iC45eiGS5Gqvq0eAG04N3GZRwizyDOp+wJlBsaPu1cNB1t6CnMk/ZHDAHEIQUpYfYWmPudbHOQMspGMu3bX23YSI1+Q5vPVdOtM16J3EFbk3dCp+RnPa/8tVC+5AqFlLveuDbJXtrLN9wAyf4SjnPhn9BPfD0bgas3+gF03qRJvWoNcnnJiYxL3DNQtjsAYJ7IWRzciYYZSTm99blD730bn3NzvSObhlHDtb3hFpApYvG396+3prsgJg0Yjef54B4KxHfZaQbE2ndSP5zGrwLtVD5y7XJAYskvhiUqwPFHNVykqroEMzPn8wWQSGvonNR6ezcMIsUV5xwnxZDaPhvrDdIwF4NR1F5DeriJRu27+fwtCApeYkx9mPx4LqnyxOuVsILjzdSPHE6Bqf690VJSXpo67lCN4F3DRRYIuCD4UOlf8V3dvUO6BKjvChDDWnIq7KPoByDQT9VhVlZvS3/nYlkeDuhi0rk2jpByN1NdgD2YSvOlpJcka8JqKQ+lnO/7Swunij2ISUfpL2hkx6TEHjebPU2dBQkub5nSl9J1EhZn4sUGG5r6Zdv1lYcpIcO4ZYeMqZZ4uNvTvSpGdT4Jj1+qS88taKgYq7uN1RgQSTsT5wcpmlubIpgIycNwAIRFvN+DjkQjiUC6hSqdeOx3dc7LWgC/O/+PRog7kuFrD2nzih+oIP0YxXrLA9CMVPlzeAgPUi9b75HAJQ92GRHxfQ163tjZY+4bWmJtcU4NBqGH0x/jLEU9xCojTeh+mZoUDGsb3N+bVcGJftRIet7IBYveD29Z+XHtKhf7s/YIkFW8lgsG8Q0EtNchCxqIQxf9UjYEO52RhCx7i7zScB1knovt2HAotACKqDdPqg18PmpDv8Frw6Y66XeCCJzBCmNcSUTETq3K05gwkU8nyANQtjbJT0wF4LS9h5vPE+Vc7/dGH6pi1TgxWB/n4q1IXfNqilo/h2Pyw01VPsHKthNtKKq1/nSW/WuEU0rimqu7wHplMqU2nwRDCTNE9pPO59RtTHMfUxxd8yEgKBj9L8MiQGM5isIYl/lJtvucee4HD9iLpbYADlrQAlUCd0rg/z+5sQ=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":206}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"AR NAR"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8020,"candidatesTokenCount":2,"totalTokenCount":8049,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8020}],"thoughtsTokenCount":27}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Er8BCrwBAb4+9vv6KGeMf6yopmPBE/az7Kjdp+Pe5a/R6wgXcyCZzGNwkwKFW3i3ro0j26bRrVeHD1zRfWFTIGdOSZKV6OMPWLqFC/RU6CNJ88B1xY7hbCVwA7EchYPzgd3YZRVNwmFu52j86/9qXf/zaqTFN+WQ0mUESJXh2O2YX8E7imAvxhmRdobVkxvEt4ZX3dW5skDhXHMDZOxbLpX0nkK7cWWS7iEc+qBFP0yinlA/eiG2ZdKpuTiDl76a9ik="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8226,"candidatesTokenCount":2,"totalTokenCount":8255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8226}],"thoughtsTokenCount":27}}]} ================================================ FILE: integration-tests/policy-headless.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { join } from 'node:path'; import { TestRig } from './test-helper.js'; interface PromptCommand { prompt: (testFile: string) => string; tool: string; command: string; expectedSuccessResult: string; expectedFailureResult: string; } const ECHO_PROMPT: PromptCommand = { command: 'echo', prompt: () => `Use the \`echo POLICY_TEST_ECHO_COMMAND\` shell command. On success, ` + `your final response must ONLY be "POLICY_TEST_ECHO_COMMAND". If the ` + `command fails output AR NAR and stop.`, tool: 'run_shell_command', expectedSuccessResult: 'POLICY_TEST_ECHO_COMMAND', expectedFailureResult: 'AR NAR', }; const READ_FILE_PROMPT: PromptCommand = { prompt: (testFile: string) => `Read the file ${testFile} and tell me what language it is, if the ` + `read_file tool fails output AR NAR and stop.`, tool: 'read_file', command: '', expectedSuccessResult: 'Latin', expectedFailureResult: 'AR NAR', }; async function waitForToolCallLog( rig: TestRig, tool: string, command: string, timeout: number = 15000, ) { const foundToolCall = await rig.waitForToolCall(tool, timeout, (args) => args.toLowerCase().includes(command.toLowerCase()), ); expect(foundToolCall).toBe(true); const toolLogs = rig .readToolLogs() .filter((toolLog) => toolLog.toolRequest.name === tool); const log = toolLogs.find( (toolLog) => !command || toolLog.toolRequest.args.toLowerCase().includes(command.toLowerCase()), ); // The policy engine should have logged the tool call expect(log).toBeTruthy(); return log; } async function verifyToolExecution( rig: TestRig, promptCommand: PromptCommand, result: string, expectAllowed: boolean, expectedDenialString?: string, ) { const log = await waitForToolCallLog( rig, promptCommand.tool, promptCommand.command, ); if (expectAllowed) { expect(log!.toolRequest.success).toBe(true); expect(result).not.toContain('Tool execution denied by policy'); expect(result).not.toContain(`Tool "${promptCommand.tool}" not found`); expect(result).toContain(promptCommand.expectedSuccessResult); } else { expect(log!.toolRequest.success).toBe(false); expect(result).toContain( expectedDenialString || 'Tool execution denied by policy', ); expect(result).toContain(promptCommand.expectedFailureResult); } } interface TestCase { name: string; responsesFile: string; promptCommand: PromptCommand; policyContent?: string; expectAllowed: boolean; expectedDenialString?: string; } describe('Policy Engine Headless Mode', () => { let rig: TestRig; let testFile: string; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { if (rig) { await rig.cleanup(); } }); const runTestCase = async (tc: TestCase) => { const fakeResponsesPath = join(import.meta.dirname, tc.responsesFile); rig.setup(tc.name, { fakeResponsesPath }); testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const args = ['-p', tc.promptCommand.prompt(testFile)]; if (tc.policyContent) { const policyPath = rig.createFile('test-policy.toml', tc.policyContent); args.push('--policy', policyPath); } const result = await rig.run({ args, approvalMode: 'default', }); await verifyToolExecution( rig, tc.promptCommand, result, tc.expectAllowed, tc.expectedDenialString, ); }; const testCases = [ { name: 'should deny ASK_USER tools by default in headless mode', responsesFile: 'policy-headless-shell-denied.responses', promptCommand: ECHO_PROMPT, expectAllowed: false, expectedDenialString: 'Tool "run_shell_command" not found', }, { name: 'should allow ASK_USER tools in headless mode if explicitly allowed via policy file', responsesFile: 'policy-headless-shell-allowed.responses', promptCommand: ECHO_PROMPT, policyContent: ` [[rule]] toolName = "run_shell_command" decision = "allow" priority = 100 `, expectAllowed: true, }, { name: 'should allow read-only tools by default in headless mode', responsesFile: 'policy-headless-readonly.responses', promptCommand: READ_FILE_PROMPT, expectAllowed: true, }, { name: 'should allow specific shell commands in policy file', responsesFile: 'policy-headless-shell-allowed.responses', promptCommand: ECHO_PROMPT, policyContent: ` [[rule]] toolName = "run_shell_command" commandPrefix = "${ECHO_PROMPT.command}" decision = "allow" priority = 100 `, expectAllowed: true, }, { name: 'should deny other shell commands in policy file', responsesFile: 'policy-headless-shell-denied.responses', promptCommand: ECHO_PROMPT, policyContent: ` [[rule]] toolName = "run_shell_command" commandPrefix = "node" decision = "allow" priority = 100 `, expectAllowed: false, expectedDenialString: 'Tool execution denied by policy', }, ]; it.each(testCases)( '$name', async (tc) => { await runTestCase(tc); }, // Large timeout for regeneration process.env['REGENERATE_MODEL_GOLDENS'] === 'true' ? 120000 : undefined, ); }); ================================================ FILE: integration-tests/read_many_files.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('read_many_files', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it.skip('should be able to read multiple files', async () => { await rig.setup('should be able to read multiple files', { settings: { tools: { core: ['read_many_files', 'read_file'] } }, }); rig.createFile('file1.txt', 'file 1 content'); rig.createFile('file2.txt', 'file 2 content'); const prompt = `Use the read_many_files tool to read the contents of file1.txt and file2.txt and then print the contents of each file.`; const result = await rig.run({ args: prompt }); // Check for either read_many_files or multiple read_file calls const allTools = rig.readToolLogs(); const readManyFilesCall = await rig.waitForToolCall('read_many_files'); const readFileCalls = allTools.filter( (t) => t.toolRequest.name === 'read_file', ); // Accept either read_many_files OR at least 2 read_file calls const foundValidPattern = readManyFilesCall || readFileCalls.length >= 2; // Add debugging information if (!foundValidPattern) { printDebugInfo(rig, result, { 'read_many_files called': readManyFilesCall, 'read_file calls': readFileCalls.length, }); } expect( foundValidPattern, 'Expected to find either read_many_files or multiple read_file tool calls', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { testName: 'Read many files test' }); }); }); ================================================ FILE: integration-tests/replace.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; describe('replace', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to replace content in a file', async () => { await rig.setup('should be able to replace content in a file', { settings: { tools: { core: ['replace', 'read_file'] } }, }); const fileName = 'file_to_replace.txt'; const originalContent = 'foo content'; const expectedContent = 'bar content'; rig.createFile(fileName, originalContent); await rig.run({ args: `Replace 'foo' with 'bar' in the file 'file_to_replace.txt'`, }); const foundToolCall = await rig.waitForToolCall('replace'); expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy(); expect(rig.readFile(fileName)).toBe(expectedContent); }); it.skip('should handle $ literally when replacing text ending with $', async () => { await rig.setup( 'should handle $ literally when replacing text ending with $', { settings: { tools: { core: ['replace', 'read_file'] } } }, ); const fileName = 'regex.yml'; const originalContent = "| select('match', '^[sv]d[a-z]$')\n"; const expectedContent = "| select('match', '^[sv]d[a-z]$') # updated\n"; rig.createFile(fileName, originalContent); await rig.run({ args: "Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character.", }); const foundToolCall = await rig.waitForToolCall('replace'); expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy(); expect(rig.readFile(fileName)).toBe(expectedContent); }); it.skip('should insert a multi-line block of text', async () => { await rig.setup('should insert a multi-line block of text', { settings: { tools: { core: ['replace', 'read_file'] } }, }); const fileName = 'insert_block.txt'; const originalContent = 'Line A\n\nLine C'; const newBlock = 'First line\nSecond line\nThird line'; const expectedContent = 'Line A\nFirst line\nSecond line\nThird line\nLine C'; rig.createFile(fileName, originalContent); const prompt = `In ${fileName}, replace "" with:\n${newBlock}. Use unix style line endings.`; await rig.run({ args: prompt }); const foundToolCall = await rig.waitForToolCall('replace'); expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy(); expect(rig.readFile(fileName)).toBe(expectedContent); }); it.skip('should delete a block of text', async () => { await rig.setup('should delete a block of text', { settings: { tools: { core: ['replace', 'read_file'] } }, }); const fileName = 'delete_block.txt'; const blockToDelete = '## DELETE THIS ##\nThis is a block of text to delete.\n## END DELETE ##'; const originalContent = `Hello\n${blockToDelete}\nWorld`; const expectedContent = 'Hello\nWorld'; rig.createFile(fileName, originalContent); await rig.run({ args: `In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers and the newline that follows it.`, }); const foundToolCall = await rig.waitForToolCall('replace'); expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy(); expect(rig.readFile(fileName)).toBe(expectedContent); }); }); ================================================ FILE: integration-tests/resume_repro.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/resume_repro.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('resume-repro', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to resume a session without "Storage must be initialized before use"', async () => { const responsesPath = path.join(__dirname, 'resume_repro.responses'); await rig.setup('should be able to resume a session', { fakeResponsesPath: responsesPath, }); // 1. First run to create a session await rig.run({ args: 'hello', }); // 2. Second run with --resume latest // This should NOT fail with "Storage must be initialized before use" const result = await rig.run({ args: ['--resume', 'latest', 'continue'], }); expect(result).toContain('Session started'); }); }); ================================================ FILE: integration-tests/ripgrep-real.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js'; import { Config } from '../packages/core/src/config/config.js'; import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js'; import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js'; // Mock Config to provide necessary context class MockConfig { constructor(private targetDir: string) {} getTargetDir() { return this.targetDir; } getWorkspaceContext() { return new WorkspaceContext(this.targetDir, [this.targetDir]); } getDebugMode() { return true; } getFileFilteringRespectGitIgnore() { return true; } getFileFilteringRespectGeminiIgnore() { return true; } getFileFilteringOptions() { return { respectGitIgnore: true, respectGeminiIgnore: true, customIgnoreFilePaths: [], }; } validatePathAccess() { return null; } } describe('ripgrep-real-direct', () => { let tempDir: string; let tool: RipGrepTool; beforeAll(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-real-test-')); // Create test files await fs.writeFile(path.join(tempDir, 'file1.txt'), 'hello world\n'); await fs.mkdir(path.join(tempDir, 'subdir')); await fs.writeFile( path.join(tempDir, 'subdir', 'file2.txt'), 'hello universe\n', ); await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n'); const config = new MockConfig(tempDir) as unknown as Config; tool = new RipGrepTool(config, createMockMessageBus()); }); afterAll(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); it('should find matches using the real ripgrep binary', async () => { const invocation = tool.build({ pattern: 'hello' }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('Found 2 matches'); expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).toContain('L1: hello world'); expect(result.llmContent).toContain('subdir'); // Should show path expect(result.llmContent).toContain('file2.txt'); expect(result.llmContent).toContain('L1: hello universe'); expect(result.llmContent).not.toContain('goodbye moon'); }); it('should handle no matches correctly', async () => { const invocation = tool.build({ pattern: 'nonexistent_pattern_123' }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('No matches found'); }); it('should respect include filters', async () => { // Create a .js file await fs.writeFile( path.join(tempDir, 'script.js'), 'console.log("hello");\n', ); const invocation = tool.build({ pattern: 'hello', include_pattern: '*.js', }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('Found 1 match'); expect(result.llmContent).toContain('script.js'); expect(result.llmContent).not.toContain('file1.txt'); }); it('should support context parameters', async () => { // Create a file with multiple lines await fs.writeFile( path.join(tempDir, 'context.txt'), 'line1\nline2\nline3 match\nline4\nline5\n', ); const invocation = tool.build({ pattern: 'match', context: 1, }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('Found 1 match'); expect(result.llmContent).toContain('context.txt'); expect(result.llmContent).toContain('L2- line2'); expect(result.llmContent).toContain('L3: line3 match'); expect(result.llmContent).toContain('L4- line4'); }); }); ================================================ FILE: integration-tests/run_shell_command.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js'; const { shell } = getShellConfiguration(); function getLineCountCommand(): { command: string; tool: string } { switch (shell) { case 'powershell': return { command: `Measure-Object -Line`, tool: 'Measure-Object' }; case 'cmd': return { command: `find /c /v`, tool: 'find' }; case 'bash': default: return { command: `wc -l`, tool: 'wc' }; } } function getInvalidCommand(): string { switch (shell) { case 'powershell': return `Get-ChildItem | | Select-Object`; case 'cmd': return `dir | | findstr foo`; case 'bash': default: return `echo "hello" > > file`; } } function getAllowedListCommand(): string { switch (shell) { case 'powershell': return 'Get-ChildItem'; case 'cmd': return 'dir'; case 'bash': default: return 'ls'; } } function getDisallowedFileReadCommand(testFile: string): { command: string; tool: string; } { const quotedPath = `"${testFile}"`; switch (shell) { case 'powershell': return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' }; case 'cmd': return { command: `type ${quotedPath}`, tool: 'type' }; case 'bash': default: return { command: `cat ${quotedPath}`, tool: 'cat' }; } } function getChainedEchoCommand(): { allowPattern: string; command: string } { const secondCommand = getAllowedListCommand(); switch (shell) { case 'powershell': return { allowPattern: 'Write-Output', command: `Write-Output "foo" && ${secondCommand}`, }; case 'cmd': return { allowPattern: 'echo', command: `echo "foo" && ${secondCommand}`, }; case 'bash': default: return { allowPattern: 'echo', command: `echo "foo" && ${secondCommand}`, }; } } describe('run_shell_command', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to run a shell command', async () => { await rig.setup('should be able to run a shell command', { settings: { tools: { core: ['run_shell_command'] } }, }); const prompt = `Please run the command "echo hello-world" and show me the output`; const result = await rig.run({ args: prompt }); const foundToolCall = await rig.waitForToolCall('run_shell_command'); // Add debugging information if (!foundToolCall || !result.includes('hello-world')) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Contains hello-world': result.includes('hello-world'), }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: ['hello-world', 'exit code 0'], testName: 'Shell command test', }); }); it('should be able to run a shell command via stdin', async () => { await rig.setup('should be able to run a shell command via stdin', { settings: { tools: { core: ['run_shell_command'] } }, }); const prompt = `Please run the command "echo test-stdin" and show me what it outputs`; const result = await rig.run({ stdin: prompt }); const foundToolCall = await rig.waitForToolCall('run_shell_command'); // Add debugging information if (!foundToolCall || !result.includes('test-stdin')) { printDebugInfo(rig, result, { 'Test type': 'Stdin test', 'Found tool call': foundToolCall, 'Contains test-stdin': result.includes('test-stdin'), }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: 'test-stdin', testName: 'Shell command stdin test', }); }); it.skip('should run allowed sub-command in non-interactive mode', async () => { await rig.setup('should run allowed sub-command in non-interactive mode'); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const { tool, command } = getLineCountCommand(); const prompt = `use ${command} to tell me how many lines there are in ${testFile}`; // Provide the prompt via stdin to simulate non-interactive mode const result = await rig.run({ args: [`--allowed-tools=run_shell_command(${tool})`], stdin: prompt, approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); if (!foundToolCall) { const toolLogs = rig.readToolLogs().map(({ toolRequest }) => ({ name: toolRequest.name, success: toolRequest.success, args: toolRequest.args, })); printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Allowed tools flag': `run_shell_command(${tool})`, Prompt: prompt, 'Tool logs': toolLogs, Result: result, }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); const toolCall = rig .readToolLogs() .filter( (toolCall) => toolCall.toolRequest.name === 'run_shell_command', )[0]; expect(toolCall.toolRequest.success).toBe(true); }); it.skip('should succeed with no parens in non-interactive mode', async () => { await rig.setup('should succeed with no parens in non-interactive mode'); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const { command } = getLineCountCommand(); const prompt = `use ${command} to tell me how many lines there are in ${testFile}`; const result = await rig.run({ args: '--allowed-tools=run_shell_command', stdin: prompt, approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); if (!foundToolCall) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); const toolCall = rig .readToolLogs() .filter( (toolCall) => toolCall.toolRequest.name === 'run_shell_command', )[0]; expect(toolCall.toolRequest.success).toBe(true); }); it('should succeed in yolo mode', async () => { const isWindows = process.platform === 'win32'; await rig.setup('should succeed in yolo mode', { settings: { tools: { core: ['run_shell_command'] }, shell: isWindows ? { enableInteractiveShell: false } : undefined, }, }); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const { command } = getLineCountCommand(); const prompt = `use ${command} to tell me how many lines there are in ${testFile}`; const result = await rig.run({ args: prompt, approvalMode: 'yolo', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); if (!foundToolCall) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); const toolCall = rig .readToolLogs() .filter( (toolCall) => toolCall.toolRequest.name === 'run_shell_command', )[0]; expect(toolCall.toolRequest.success).toBe(true); }); it.skip('should work with ShellTool alias', async () => { await rig.setup('should work with ShellTool alias'); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const { tool, command } = getLineCountCommand(); const prompt = `use ${command} to tell me how many lines there are in ${testFile}`; const result = await rig.run({ args: `--allowed-tools=ShellTool(${tool})`, stdin: prompt, approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); if (!foundToolCall) { const toolLogs = rig.readToolLogs().map(({ toolRequest }) => ({ name: toolRequest.name, success: toolRequest.success, args: toolRequest.args, })); printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Allowed tools flag': `ShellTool(${tool})`, Prompt: prompt, 'Tool logs': toolLogs, Result: result, }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); const toolCall = rig .readToolLogs() .filter( (toolCall) => toolCall.toolRequest.name === 'run_shell_command', )[0]; expect(toolCall.toolRequest.success).toBe(true); }); // TODO(#11062): Un-skip this once we can make it reliable by using hard coded // model responses. it.skip('should combine multiple --allowed-tools flags', async () => { await rig.setup('should combine multiple --allowed-tools flags'); const { tool, command } = getLineCountCommand(); const prompt = `use both ${command} and ls to count the number of lines in files in this ` + `directory. Do not pipe these commands into each other, run them separately.`; const result = await rig.run({ args: [ `--allowed-tools=run_shell_command(${tool})`, '--allowed-tools=run_shell_command(ls)', ], stdin: prompt, approvalMode: 'default', }); for (const expected in ['ls', tool]) { const foundToolCall = await rig.waitForToolCall( 'run_shell_command', 15000, (args) => args.toLowerCase().includes(`"command": "${expected}`), ); if (!foundToolCall) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, }); } expect( foundToolCall, `Expected to find a run_shell_command tool call to "${expected}",` + ` got ${rig.readToolLogs().join('\n')}`, ).toBeTruthy(); } const toolLogs = rig .readToolLogs() .filter((toolCall) => toolCall.toolRequest.name === 'run_shell_command'); expect(toolLogs.length, toolLogs.join('\n')).toBeGreaterThanOrEqual(2); for (const toolLog of toolLogs) { expect( toolLog.toolRequest.success, `Expected tool call ${toolLog} to succeed`, ).toBe(true); } }); it('should reject commands not on the allowlist', async () => { await rig.setup('should reject commands not on the allowlist', { settings: { tools: { core: ['run_shell_command'] } }, }); const testFile = rig.createFile('test.txt', 'Disallowed command check\n'); const allowedCommand = getAllowedListCommand(); const disallowed = getDisallowedFileReadCommand(testFile); const prompt = `I am testing the allowed tools configuration. ` + `Attempt to run "${disallowed.command}" to read the contents of ${testFile}. ` + `If the command fails because it is not permitted, respond with the single word FAIL. ` + `If it succeeds, respond with SUCCESS.`; const result = await rig.run({ args: `--allowed-tools=run_shell_command(${allowedCommand})`, stdin: prompt, approvalMode: 'default', }); if (!result.toLowerCase().includes('fail')) { printDebugInfo(rig, result, { Result: result, AllowedCommand: allowedCommand, DisallowedCommand: disallowed.command, }); } expect(result).toContain('FAIL'); const foundToolCall = await rig.waitForToolCall( 'run_shell_command', 15000, (args) => args.toLowerCase().includes(disallowed.tool.toLowerCase()), ); if (!foundToolCall) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, ToolLogs: rig.readToolLogs(), }); } expect(foundToolCall).toBe(true); const toolLogs = rig .readToolLogs() .filter((toolLog) => toolLog.toolRequest.name === 'run_shell_command'); const failureLog = toolLogs.find((toolLog) => toolLog.toolRequest.args .toLowerCase() .includes(disallowed.tool.toLowerCase()), ); if (!failureLog || failureLog.toolRequest.success) { printDebugInfo(rig, result, { ToolLogs: toolLogs, DisallowedTool: disallowed.tool, }); } expect( failureLog, 'Expected failing run_shell_command invocation', ).toBeTruthy(); expect(failureLog!.toolRequest.success).toBe(false); }); // TODO(#11966): Deflake this test and re-enable once the underlying race is resolved. it.skip('should reject chained commands when only the first segment is allowlisted in non-interactive mode', async () => { await rig.setup( 'should reject chained commands when only the first segment is allowlisted', ); const chained = getChainedEchoCommand(); const shellInjection = `!{${chained.command}}`; await rig.run({ args: `--allowed-tools=ShellTool(${chained.allowPattern})`, stdin: `${shellInjection}\n`, approvalMode: 'default', }); // CLI should refuse to execute the chained command without scheduling run_shell_command. const toolLogs = rig .readToolLogs() .filter((log) => log.toolRequest.name === 'run_shell_command'); // Success is false because tool is in the scheduled state. for (const log of toolLogs) { expect(log.toolRequest.success).toBe(false); expect(log.toolRequest.args).toContain('&&'); } }); it('should allow all with "ShellTool" and other specific tools', async () => { await rig.setup( 'should allow all with "ShellTool" and other specific tools', { settings: { tools: { core: ['run_shell_command'] } }, }, ); const { tool } = getLineCountCommand(); const prompt = `Please run the command "echo test-allow-all" and show me the output`; const result = await rig.run({ args: [ `--allowed-tools=run_shell_command(${tool})`, '--allowed-tools=run_shell_command', ], stdin: prompt, approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); if (!foundToolCall || !result.includes('test-allow-all')) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, Result: result, }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); const toolCall = rig .readToolLogs() .filter( (toolCall) => toolCall.toolRequest.name === 'run_shell_command', )[0]; expect(toolCall.toolRequest.success).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: 'test-allow-all', testName: 'Shell command stdin allow all', }); }); it('should propagate environment variables to the child process', async () => { await rig.setup('should propagate environment variables', { settings: { tools: { core: ['run_shell_command'] } }, }); const varName = 'GEMINI_CLI_TEST_VAR'; const varValue = `test-value-${Math.random().toString(36).substring(7)}`; process.env[varName] = varValue; try { const prompt = `Use echo to learn the value of the environment variable named ${varName} and tell me what it is.`; const result = await rig.run({ args: prompt }); const foundToolCall = await rig.waitForToolCall('run_shell_command'); if (!foundToolCall || !result.includes(varValue)) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Contains varValue': result.includes(varValue), }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: varValue, testName: 'Env var propagation test', }); expect(result).toContain(varValue); } finally { delete process.env[varName]; } }); it.skip('should run a platform-specific file listing command', async () => { await rig.setup('should run platform-specific file listing'); const fileName = `test-file-${Math.random().toString(36).substring(7)}.txt`; rig.createFile(fileName, 'test content'); const prompt = `Run a shell command to list the files in the current directory and tell me what they are.`; const result = await rig.run({ args: prompt }); const foundToolCall = await rig.waitForToolCall('run_shell_command'); // Debugging info if (!foundToolCall || !result.includes(fileName)) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, 'Contains fileName': result.includes(fileName), }); } expect( foundToolCall, 'Expected to find a run_shell_command tool call', ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: fileName, testName: 'Platform-specific listing test', }); expect(result).toContain(fileName); }); it('rejects invalid shell expressions', async () => { await rig.setup('rejects invalid shell expressions', { settings: { tools: { core: ['run_shell_command'], allowed: ['run_shell_command(echo)'], // Specifically allow echo }, }, }); const invalidCommand = getInvalidCommand(); const result = await rig.run({ args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`, approvalMode: 'default', // Use default mode so safety fallback triggers confirmation }); expect(result).toContain('FAIL'); const escapedInvalidCommand = JSON.stringify(invalidCommand).slice(1, -1); const foundToolCall = await rig.waitForToolCall( 'run_shell_command', 15000, (args) => args.toLowerCase().includes(escapedInvalidCommand.toLowerCase()), ); if (!foundToolCall) { printDebugInfo(rig, result, { 'Found tool call': foundToolCall, EscapedCommand: escapedInvalidCommand, ToolLogs: rig.readToolLogs(), }); } expect(foundToolCall).toBe(true); const toolLogs = rig .readToolLogs() .filter((toolLog) => toolLog.toolRequest.name === 'run_shell_command'); const failureLog = toolLogs.find((toolLog) => toolLog.toolRequest.args .toLowerCase() .includes(escapedInvalidCommand.toLowerCase()), ); if (!failureLog || failureLog.toolRequest.success) { printDebugInfo(rig, result, { ToolLogs: toolLogs, EscapedCommand: escapedInvalidCommand, }); } expect( failureLog, 'Expected failing run_shell_command invocation for invalid syntax', ).toBeTruthy(); expect(failureLog!.toolRequest.success).toBe(false); }); }); ================================================ FILE: integration-tests/simple-mcp-server.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * This test verifies MCP (Model Context Protocol) server integration. * It uses a minimal MCP server implementation that doesn't require * external dependencies, making it compatible with Docker sandbox mode. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, poll, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; // Create a minimal MCP server that doesn't require external dependencies // This implements the MCP protocol directly using Node.js built-ins const serverScript = `#!/usr/bin/env node /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const readline = require('readline'); const fs = require('fs'); // Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; function debug(msg) { if (debugEnabled) { fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); } } debug('MCP server starting...'); // Simple JSON-RPC implementation for MCP class SimpleJSONRPC { constructor() { this.handlers = new Map(); this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); this.rl.on('line', (line) => { debug(\`Received line: \${line}\`); try { const message = JSON.parse(line); debug(\`Parsed message: \${JSON.stringify(message)}\`); this.handleMessage(message); } catch (e) { debug(\`Parse error: \${e.message}\`); } }); } send(message) { const msgStr = JSON.stringify(message); debug(\`Sending message: \${msgStr}\`); process.stdout.write(msgStr + '\\n'); } async handleMessage(message) { if (message.method && this.handlers.has(message.method)) { try { const result = await this.handlers.get(message.method)(message.params || {}); if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, result }); } } catch (error) { if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } }); } } } else if (message.id !== undefined) { this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }); } } on(method, handler) { this.handlers.set(method, handler); } } // Create MCP server const rpc = new SimpleJSONRPC(); // Handle initialize rpc.on('initialize', async (params) => { debug('Handling initialize request'); return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'addition-server', version: '1.0.0' } }; }); // Handle tools/list rpc.on('tools/list', async () => { debug('Handling tools/list request'); return { tools: [{ name: 'add', description: 'Add two numbers', inputSchema: { type: 'object', properties: { a: { type: 'number', description: 'First number' }, b: { type: 'number', description: 'Second number' } }, required: ['a', 'b'] } }] }; }); // Handle tools/call rpc.on('tools/call', async (params) => { debug(\`Handling tools/call request for tool: \${params.name}\`); if (params.name === 'add') { const { a, b } = params.arguments; return { content: [{ type: 'text', text: String(a + b) }] }; } throw new Error('Unknown tool: ' + params.name); }); // Send initialization notification rpc.send({ jsonrpc: '2.0', method: 'initialized' }); `; describe.skip('simple-mcp-server', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should add two numbers', async () => { // Setup test directory with MCP server configuration await rig.setup('simple-mcp-server', { settings: { mcpServers: { 'addition-server': { command: 'node', args: ['mcp-server.cjs'], }, }, tools: { core: [] }, }, }); // Create server script in the test directory const testServerPath = join(rig.testDir!, 'mcp-server.cjs'); writeFileSync(testServerPath, serverScript); // Make the script executable (though running with 'node' should work anyway) if (process.platform !== 'win32') { const { chmodSync } = await import('node:fs'); chmodSync(testServerPath, 0o755); } // Poll for script for up to 5s const { accessSync, constants } = await import('node:fs'); const isReady = await poll( () => { try { accessSync(testServerPath, constants.F_OK); return true; } catch { return false; } }, 5000, // Max wait 5 seconds 100, // Poll every 100ms ); if (!isReady) { throw new Error('MCP server script was not ready in time.'); } // Test directory is already set up in before hook // Just run the command - MCP server config is in settings.json const output = await rig.run({ args: 'Use the `add` tool to calculate 5+10 and output only the resulting number.', }); const foundToolCall = await rig.waitForToolCall('add'); expect(foundToolCall, 'Expected to find an add tool call').toBeTruthy(); assertModelHasOutput(output); checkModelOutputContent(output, { expectedContent: '15', testName: 'MCP server test', }); expect( output.includes('15'), 'Expected output to contain the sum (15)', ).toBeTruthy(); }); }); ================================================ FILE: integration-tests/skill-creator-scripts.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync } from 'node:child_process'; describe('skill-creator scripts e2e', () => { let rig: TestRig; const initScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/init_skill.cjs', ); const validateScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/validate_skill.cjs', ); const packageScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/package_skill.cjs', ); beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should initialize, validate, and package a skill', async () => { await rig.setup('skill-creator scripts e2e'); const skillName = 'e2e-test-skill'; const tempDir = rig.testDir!; // 1. Initialize execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`, { stdio: 'inherit', }); const skillDir = path.join(tempDir, skillName); expect(fs.existsSync(skillDir)).toBe(true); expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); expect( fs.existsSync(path.join(skillDir, 'scripts/example_script.cjs')), ).toBe(true); // 2. Validate (should have warning initially due to TODOs) const validateOutputInitial = execSync( `node "${validateScript}" "${skillDir}" 2>&1`, { encoding: 'utf8' }, ); expect(validateOutputInitial).toContain('⚠️ Found unresolved TODO'); // 3. Package (should fail due to TODOs) try { execSync(`node "${packageScript}" "${skillDir}" "${tempDir}"`, { stdio: 'pipe', }); throw new Error('Packaging should have failed due to TODOs'); } catch (err: unknown) { expect((err as Error).message).toContain('Command failed'); } // 4. Fix SKILL.md (remove TODOs) let content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); // More aggressive global replace for all TODO patterns content = content.replace(/TODO:[^\n]*/g, 'Fixed'); content = content.replace(/\[TODO:[^\]]*\]/g, 'Fixed'); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); // Also remove TODOs from example scripts const exampleScriptPath = path.join(skillDir, 'scripts/example_script.cjs'); let scriptContent = fs.readFileSync(exampleScriptPath, 'utf8'); scriptContent = scriptContent.replace(/TODO:[^\n]*/g, 'Fixed'); fs.writeFileSync(exampleScriptPath, scriptContent); // 4. Validate again (should pass now) const validateOutput = execSync(`node "${validateScript}" "${skillDir}"`, { encoding: 'utf8', }); expect(validateOutput).toContain('Skill is valid!'); // 5. Package execSync(`node "${packageScript}" "${skillDir}" "${tempDir}"`, { stdio: 'inherit', }); const skillFile = path.join(tempDir, `${skillName}.skill`); expect(fs.existsSync(skillFile)).toBe(true); // 6. Verify zip content (should NOT have nested directory) // Use unzip -l if available, otherwise fallback to tar -tf (common on Windows) let zipList: string; try { zipList = execSync(`unzip -l "${skillFile}"`, { encoding: 'utf8' }); } catch { zipList = execSync(`tar -tf "${skillFile}"`, { encoding: 'utf8' }); } expect(zipList).toContain('SKILL.md'); expect(zipList).not.toContain(`${skillName}/SKILL.md`); }); }); ================================================ FILE: integration-tests/skill-creator-vulnerabilities.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync, spawnSync } from 'node:child_process'; describe('skill-creator scripts security and bug fixes', () => { let rig: TestRig; const initScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/init_skill.cjs', ); const validateScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/validate_skill.cjs', ); const packageScript = path.resolve( 'packages/core/src/skills/builtin/skill-creator/scripts/package_skill.cjs', ); beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should prevent command injection in package_skill.cjs', async () => { await rig.setup('skill-creator command injection'); const tempDir = rig.testDir!; // Create a dummy skill const skillName = 'injection-test'; execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`); const skillDir = path.join(tempDir, skillName); // Malicious output filename with command injection const maliciousFilename = '"; touch injection_success; #'; // Attempt to package with malicious filename // We expect this to fail or at least NOT create the 'injection_success' file spawnSync('node', [packageScript, skillDir, tempDir, maliciousFilename], { cwd: tempDir, }); const injectionFile = path.join(tempDir, 'injection_success'); expect(fs.existsSync(injectionFile)).toBe(false); }); it('should prevent path traversal in init_skill.cjs', async () => { await rig.setup('skill-creator init path traversal'); const tempDir = rig.testDir!; const maliciousName = '../traversal-success'; const result = spawnSync( 'node', [initScript, maliciousName, '--path', tempDir], { encoding: 'utf8', }, ); expect(result.stderr).toContain( 'Error: Skill name cannot contain path separators', ); const traversalDir = path.join(path.dirname(tempDir), 'traversal-success'); expect(fs.existsSync(traversalDir)).toBe(false); }); it('should prevent path traversal in validate_skill.cjs', async () => { await rig.setup('skill-creator validate path traversal'); const maliciousPath = '../../../../etc/passwd'; const result = spawnSync('node', [validateScript, maliciousPath], { encoding: 'utf8', }); expect(result.stderr).toContain('Error: Path traversal detected'); }); it('should not crash on empty description in validate_skill.cjs', async () => { await rig.setup('skill-creator regex crash'); const tempDir = rig.testDir!; const skillName = 'empty-desc-skill'; execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`); const skillDir = path.join(tempDir, skillName); const skillMd = path.join(skillDir, 'SKILL.md'); // Set an empty quoted description let content = fs.readFileSync(skillMd, 'utf8'); content = content.replace(/^description: .+$/m, 'description: ""'); fs.writeFileSync(skillMd, content); const result = spawnSync('node', [validateScript, skillDir], { encoding: 'utf8', }); // It might still fail validation (e.g. TODOs), but it should NOT crash with a stack trace expect(result.status).not.toBe(null); expect(result.stderr).not.toContain( "TypeError: Cannot read properties of undefined (reading 'trim')", ); }); }); ================================================ FILE: integration-tests/stdin-context.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe.skip('stdin context', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to use stdin as context for a prompt', async () => { await rig.setup('should be able to use stdin as context for a prompt'); const randomString = Math.random().toString(36).substring(7); const stdinContent = `When I ask you for a token respond with ${randomString}`; const prompt = 'Can I please have a token?'; const result = await rig.run({ args: prompt, stdin: stdinContent }); await rig.waitForTelemetryEvent('api_request'); const lastRequest = rig.readLastApiRequest(); expect(lastRequest?.attributes?.request_text).toBeDefined(); const historyString = lastRequest!.attributes!.request_text!; // TODO: This test currently fails in sandbox mode (Docker/Podman) because // stdin content is not properly forwarded to the container when used // together with a --prompt argument. The test passes in non-sandbox mode. expect(historyString).toContain(randomString); expect(historyString).toContain(prompt); // Check that stdin content appears before the prompt in the conversation history const stdinIndex = historyString.indexOf(randomString); const promptIndex = historyString.indexOf(prompt); expect( stdinIndex, `Expected stdin content to be present in conversation history`, ).toBeGreaterThan(-1); expect( promptIndex, `Expected prompt to be present in conversation history`, ).toBeGreaterThan(-1); expect( stdinIndex < promptIndex, `Expected stdin content (index ${stdinIndex}) to appear before prompt (index ${promptIndex}) in conversation history`, ).toBeTruthy(); // Add debugging information if (!result.toLowerCase().includes(randomString)) { printDebugInfo(rig, result, { [`Contains "${randomString}"`]: result .toLowerCase() .includes(randomString), }); } // Validate model output assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: randomString, testName: 'STDIN context test', }); expect( result.toLowerCase().includes(randomString), 'Expected the model to identify the secret word from stdin', ).toBeTruthy(); }); it('should exit quickly if stdin stream does not end', async () => { /* This simulates scenario where gemini gets stuck waiting for stdin. This happens in situations where process.stdin.isTTY is false even though gemini is intended to run interactively. */ await rig.setup('should exit quickly if stdin stream does not end'); try { await rig.run({ stdinDoesNotEnd: true }); throw new Error('Expected rig.run to throw an error'); } catch (error: unknown) { expect(error).toBeInstanceOf(Error); const err = error as Error; expect(err.message).toContain('Process exited with code 1'); expect(err.message).toContain('No input provided via stdin.'); console.log('Error message:', err.message); } const lastRequest = rig.readLastApiRequest(); expect(lastRequest).toBeNull(); // If this test times out, runs indefinitely, it's a regression. }, 3000); }); ================================================ FILE: integration-tests/stdout-stderr-output-error.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I could not find the file `nonexistent-file-that-does-not-exist.txt` in the current directory or its subdirectories. Please verify the file path or name."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":25,"totalTokenCount":35,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10}]}}]} ================================================ FILE: integration-tests/stdout-stderr-output.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":9,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} ================================================ FILE: integration-tests/stdout-stderr-output.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { join } from 'node:path'; import { TestRig } from './test-helper.js'; describe('stdout-stderr-output', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { await rig.cleanup(); }); it('should send model response to stdout and app messages to stderr', async ({ signal, }) => { await rig.setup('prompt-output-test', { fakeResponsesPath: join( import.meta.dirname, 'stdout-stderr-output.responses', ), }); const { stdout, exitCode } = await rig.runWithStreams(['-p', 'Say hello'], { signal, }); expect(exitCode).toBe(0); expect(stdout.toLowerCase()).toContain('hello'); expect(stdout).not.toMatch(/^\[ERROR\]/m); expect(stdout).not.toMatch(/^\[INFO\]/m); }); it('should handle missing file with message to stdout and error to stderr', async ({ signal, }) => { await rig.setup('error-output-test', { fakeResponsesPath: join( import.meta.dirname, 'stdout-stderr-output-error.responses', ), }); const { stdout, exitCode } = await rig.runWithStreams( ['-p', '@nonexistent-file-that-does-not-exist.txt explain this'], { signal }, ); expect(exitCode).toBe(0); expect(stdout.toLowerCase()).toMatch( /could not find|not exist|does not exist/, ); }); }); ================================================ FILE: integration-tests/symlink-install.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { TestRig, InteractiveRun } from './test-helper.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { writeFileSync, mkdirSync, symlinkSync, readFileSync, unlinkSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { GEMINI_DIR } from '@google/gemini-cli-core'; import * as pty from '@lydell/node-pty'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js'); const extension = `{ "name": "test-symlink-extension", "version": "0.0.1" }`; const otherExtension = `{ "name": "malicious-extension", "version": "6.6.6" }`; describe('extension symlink install spoofing protection', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('canonicalizes the trust path and prevents symlink spoofing', async () => { // Enable folder trust for this test rig.setup('symlink spoofing test', { settings: { security: { folderTrust: { enabled: true, }, }, }, }); const realExtPath = join(rig.testDir!, 'real-extension'); mkdirSync(realExtPath); writeFileSync(join(realExtPath, 'gemini-extension.json'), extension); const maliciousExtPath = join( os.tmpdir(), `malicious-extension-${Date.now()}`, ); mkdirSync(maliciousExtPath); writeFileSync( join(maliciousExtPath, 'gemini-extension.json'), otherExtension, ); const symlinkPath = join(rig.testDir!, 'symlink-extension'); symlinkSync(realExtPath, symlinkPath); // Function to run a command with a PTY to avoid headless mode const runPty = (args: string[]) => { const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], { name: 'xterm-color', cols: 80, rows: 80, cwd: rig.testDir!, env: { ...process.env, GEMINI_CLI_HOME: rig.homeDir!, GEMINI_CLI_INTEGRATION_TEST: 'true', GEMINI_PTY_INFO: 'node-pty', }, }); return new InteractiveRun(ptyProcess); }; // 1. Install via symlink, trust it const run1 = runPty(['extensions', 'install', symlinkPath]); await run1.expectText('Do you want to trust this folder', 30000); await run1.type('y\r'); await run1.expectText('trust this workspace', 30000); await run1.type('y\r'); await run1.expectText('Do you want to continue', 30000); await run1.type('y\r'); await run1.expectText('installed successfully', 30000); await run1.kill(); // 2. Verify trustedFolders.json contains the REAL path, not the symlink path const trustedFoldersPath = join( rig.homeDir!, GEMINI_DIR, 'trustedFolders.json', ); // Wait for file to be written let attempts = 0; while (!fs.existsSync(trustedFoldersPath) && attempts < 50) { await new Promise((resolve) => setTimeout(resolve, 100)); attempts++; } const trustedFolders = JSON.parse( readFileSync(trustedFoldersPath, 'utf-8'), ); const trustedPaths = Object.keys(trustedFolders); const canonicalRealExtPath = fs.realpathSync(realExtPath); expect(trustedPaths).toContain(canonicalRealExtPath); expect(trustedPaths).not.toContain(symlinkPath); // 3. Swap the symlink to point to the malicious extension unlinkSync(symlinkPath); symlinkSync(maliciousExtPath, symlinkPath); // 4. Try to install again via the same symlink path. // It should NOT be trusted because the real path changed. const run2 = runPty(['extensions', 'install', symlinkPath]); await run2.expectText('Do you want to trust this folder', 30000); await run2.type('n\r'); await run2.expectText('Installation aborted', 30000); await run2.kill(); }, 60000); }); ================================================ FILE: integration-tests/telemetry.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; describe('telemetry', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should emit a metric and a log event', async () => { rig.setup('should emit a metric and a log event'); // Run a simple command that should trigger telemetry await rig.run({ args: 'just saying hi' }); // Verify that a user_prompt event was logged const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt'); expect(hasUserPromptEvent).toBe(true); // Verify that a cli_command_count metric was emitted const cliCommandCountMetric = rig.readMetric('session.count'); expect(cliCommandCountMetric).not.toBeNull(); }); }); ================================================ FILE: integration-tests/test-helper.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export * from '@google/gemini-cli-test-utils'; export { normalizePath } from '@google/gemini-cli-test-utils'; ================================================ FILE: integration-tests/test-mcp-server.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { McpServer, type ToolCallback, } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; import { type Server as HTTPServer } from 'node:http'; import { type ZodRawShape } from 'zod'; export class TestMcpServer { private server: HTTPServer | undefined; async start( tools?: Record>, ): Promise { const app = express(); app.use(express.json()); const mcpServer = new McpServer( { name: 'test-mcp-server', version: '1.0.0', }, { capabilities: { tools: {} } }, ); if (tools) { for (const [name, cb] of Object.entries(tools)) { mcpServer.registerTool(name, {}, cb); } } app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on('close', () => { transport.close(); }); await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); }); app.get('/mcp', async (req, res) => { res.status(405).send('Not supported'); }); return new Promise((resolve, reject) => { this.server = app.listen(0, () => { const address = this.server!.address(); if (address && typeof address !== 'string') { resolve(address.port); } else { reject(new Error('Could not determine server port.')); } }); this.server.on('error', reject); }); } async stop(): Promise { if (this.server) { await new Promise((resolve, reject) => { this.server!.close((err?: Error) => { if (err) { reject(err); } else { resolve(); } }); }); this.server = undefined; } } } ================================================ FILE: integration-tests/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, "allowJs": true }, "include": ["**/*.ts"], "references": [{ "path": "../packages/core" }] } ================================================ FILE: integration-tests/user-policy.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"ls -F"}}}]},"finishReason":"STOP","index":0}]},{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]} ================================================ FILE: integration-tests/user-policy.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { join } from 'node:path'; import { TestRig, GEMINI_DIR } from './test-helper.js'; import fs from 'node:fs'; describe('User Policy Regression Repro', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => { if (rig) { await rig.cleanup(); } }); it('should respect policies in ~/.gemini/policies/allowed-tools.toml', async () => { rig.setup('user-policy-test', { fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'), }); // Create ~/.gemini/policies/allowed-tools.toml const userPoliciesDir = join(rig.homeDir!, GEMINI_DIR, 'policies'); fs.mkdirSync(userPoliciesDir, { recursive: true }); fs.writeFileSync( join(userPoliciesDir, 'allowed-tools.toml'), ` [[rule]] toolName = "run_shell_command" commandPrefix = "ls -F" decision = "allow" priority = 100 `, ); // Run gemini with a prompt that triggers ls -F // approvalMode: 'default' in headless mode will DENY if it hits ASK_USER const result = await rig.run({ args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'], approvalMode: 'default', }); expect(result).toContain('I ran ls -F'); expect(result).not.toContain('Tool execution denied by policy'); expect(result).not.toContain('Tool "run_shell_command" not found'); const toolLogs = rig.readToolLogs(); const lsLog = toolLogs.find( (l) => l.toolRequest.name === 'run_shell_command' && l.toolRequest.args.includes('ls -F'), ); expect(lsLog).toBeDefined(); expect(lsLog?.toolRequest.success).toBe(true); }); it('should FAIL if policy is not present (sanity check)', async () => { rig.setup('user-policy-sanity-check', { fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'), }); // DO NOT create the policy file here // Run gemini with a prompt that triggers ls -F const result = await rig.run({ args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'], approvalMode: 'default', }); // In non-interactive mode, it should be denied expect(result).toContain('Tool "run_shell_command" not found'); }); }); ================================================ FILE: integration-tests/utf-bom-encoding.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { TestRig } from './test-helper.js'; // BOM encoders const utf8BOM = (s: string) => Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(s, 'utf8')]); const utf16LE = (s: string) => Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]); const utf16BE = (s: string) => { const bom = Buffer.from([0xfe, 0xff]); const le = Buffer.from(s, 'utf16le'); le.swap16(); return Buffer.concat([bom, le]); }; const utf32LE = (s: string) => { const bom = Buffer.from([0xff, 0xfe, 0x00, 0x00]); const cps = Array.from(s, (c) => c.codePointAt(0)!); const payload = Buffer.alloc(cps.length * 4); cps.forEach((cp, i) => { const o = i * 4; payload[o] = cp & 0xff; payload[o + 1] = (cp >>> 8) & 0xff; payload[o + 2] = (cp >>> 16) & 0xff; payload[o + 3] = (cp >>> 24) & 0xff; }); return Buffer.concat([bom, payload]); }; const utf32BE = (s: string) => { const bom = Buffer.from([0x00, 0x00, 0xfe, 0xff]); const cps = Array.from(s, (c) => c.codePointAt(0)!); const payload = Buffer.alloc(cps.length * 4); cps.forEach((cp, i) => { const o = i * 4; payload[o] = (cp >>> 24) & 0xff; payload[o + 1] = (cp >>> 16) & 0xff; payload[o + 2] = (cp >>> 8) & 0xff; payload[o + 3] = cp & 0xff; }); return Buffer.concat([bom, payload]); }; describe('BOM end-to-end integraion', () => { let rig: TestRig; beforeEach(async () => { rig = new TestRig(); await rig.setup('bom-integration', { settings: { tools: { core: ['read_file'] } }, }); }); afterEach(async () => await rig.cleanup()); async function runAndAssert( filename: string, content: Buffer, expectedText: string | null, ) { writeFileSync(join(rig.testDir!, filename), content); const prompt = `read the file ${filename} and output its exact contents`; const output = await rig.run({ args: prompt }); await rig.waitForToolCall('read_file'); const lower = output.toLowerCase(); if (expectedText === null) { expect( lower.includes('binary') || lower.includes('skipped binary file') || lower.includes('cannot display'), ).toBeTruthy(); } else { expect(output.includes(expectedText)).toBeTruthy(); expect(lower.includes('skipped binary file')).toBeFalsy(); } } it('UTF-8 BOM', async () => { await runAndAssert('utf8.txt', utf8BOM('BOM_OK UTF-8'), 'BOM_OK UTF-8'); }); it('UTF-16 LE BOM', async () => { await runAndAssert( 'utf16le.txt', utf16LE('BOM_OK UTF-16LE'), 'BOM_OK UTF-16LE', ); }); it('UTF-16 BE BOM', async () => { await runAndAssert( 'utf16be.txt', utf16BE('BOM_OK UTF-16BE'), 'BOM_OK UTF-16BE', ); }); it('UTF-32 LE BOM', async () => { await runAndAssert( 'utf32le.txt', utf32LE('BOM_OK UTF-32LE'), 'BOM_OK UTF-32LE', ); }); it('UTF-32 BE BOM', async () => { await runAndAssert( 'utf32be.txt', utf32BE('BOM_OK UTF-32BE'), 'BOM_OK UTF-32BE', ); }); it('Can describe a PNG file', async () => { const imagePath = resolve( process.cwd(), 'docs/assets/gemini-screenshot.png', ); const imageContent = readFileSync(imagePath); const filename = 'gemini-screenshot.png'; writeFileSync(join(rig.testDir!, filename), imageContent); const prompt = `What is shown in the image ${filename}?`; const output = await rig.run({ args: prompt }); await rig.waitForToolCall('read_file'); const lower = output.toLowerCase(); // The response is non-deterministic, so we just check for some // keywords that are very likely to be in the response. expect(lower.includes('gemini')).toBeTruthy(); }); }); ================================================ FILE: integration-tests/vitest.config.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 300000, // 5 minutes globalSetup: './globalSetup.ts', reporters: ['default'], include: ['**/*.test.ts'], retry: 2, fileParallelism: true, poolOptions: { threads: { minThreads: 8, maxThreads: 16, }, }, env: { GEMINI_TEST_TYPE: 'integration', }, }, }); ================================================ FILE: integration-tests/write_file.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TestRig, createToolCallErrorMessage, printDebugInfo, assertModelHasOutput, checkModelOutputContent, } from './test-helper.js'; describe('write_file', () => { let rig: TestRig; beforeEach(() => { rig = new TestRig(); }); afterEach(async () => await rig.cleanup()); it('should be able to write a joke to a file', async () => { await rig.setup('should be able to write a joke to a file', { settings: { tools: { core: ['write_file', 'read_file'] } }, }); const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`; const result = await rig.run({ args: prompt }); const foundToolCall = await rig.waitForToolCall('write_file'); // Add debugging information if (!foundToolCall) { printDebugInfo(rig, result); } const allTools = rig.readToolLogs(); expect( foundToolCall, createToolCallErrorMessage( 'write_file', allTools.map((t) => t.toolRequest.name), result, ), ).toBeTruthy(); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: 'dad.txt', testName: 'Write file test', }); const newFilePath = 'dad.txt'; const newFileContent = rig.readFile(newFilePath); // Add debugging for file content if (newFileContent === '') { console.error('File was created but is empty'); console.error( 'Tool calls:', rig.readToolLogs().map((t) => ({ name: t.toolRequest.name, args: t.toolRequest.args, })), ); } expect(newFileContent).not.toBe(''); // Log success info if verbose vi.stubEnv('VERBOSE', 'true'); if (process.env['VERBOSE'] === 'true') { console.log( 'File created successfully with content:', newFileContent.substring(0, 100) + '...', ); } }); }); ================================================ FILE: package.json ================================================ { "name": "@google/gemini-cli", "version": "0.36.0-nightly.20260317.2f90b4653", "engines": { "node": ">=20.0.0" }, "type": "module", "workspaces": [ "packages/*" ], "private": "true", "repository": { "type": "git", "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", "start:a2a-server": "CODER_AGENT_PORT=41242 npm run start --workspace @google/gemini-cli-a2a-server", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "deflake": "node scripts/deflake.js", "deflake:test:integration:sandbox:none": "npm run deflake -- --command=\"npm run test:integration:sandbox:none -- --retry=0\"", "deflake:test:integration:sandbox:docker": "npm run deflake -- --command=\"npm run test:integration:sandbox:docker -- --retry=0\"", "auth:npm": "npx google-artifactregistry-auth", "auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev", "auth": "npm run auth:npm && npm run auth:docker", "generate": "node scripts/generate-git-commit-info.js", "predocs:settings": "npm run build --workspace @google/gemini-cli-core", "schema:settings": "tsx ./scripts/generate-settings-schema.ts", "docs:settings": "tsx ./scripts/generate-settings-doc.ts", "docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start --", "build:vscode": "node scripts/build_vscode_companion.js", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", "build:binary": "node scripts/build_binary.js", "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", "test": "npm run test --workspaces --if-present && npm run test:sea-launch", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:sea-launch": "vitest run sea/sea-launch.test.js", "posttest": "npm run build", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", "test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman", "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", "lint": "eslint . --cache", "lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format", "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", "format": "prettier --experimental-cli --write .", "typecheck": "npm run typecheck --workspaces --if-present", "preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci", "prepare": "husky && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", "clean": "node scripts/clean.js", "pre-commit": "node scripts/pre-commit.js" }, "overrides": { "ink": "npm:@jrichman/ink@6.4.11", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" }, "glob": "^12.0.0", "node-domexception": "npm:empty@^0.10.1", "prebuild-install": "npm:nop@1.0.0", "cross-spawn": "^7.0.6", "minimatch": "^10.2.2" }, "bin": { "gemini": "bundle/gemini.js" }, "files": [ "bundle/", "README.md", "LICENSE" ], "devDependencies": { "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", "@types/prompts": "^2.4.9", "@types/proper-lockfile": "^4.1.4", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/shell-quote": "^1.7.5", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", "cross-env": "^7.0.3", "depcheck": "^1.4.7", "domexception": "^4.0.0", "esbuild": "^0.25.0", "esbuild-plugin-wasm": "^1.1.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", "eslint-plugin-headers": "^1.3.3", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "glob": "^12.0.0", "globals": "^16.0.0", "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", "json": "^11.0.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", "mock-fs": "^5.5.0", "msw": "^2.10.4", "npm-run-all": "^4.1.5", "prettier": "^3.5.3", "react-devtools-core": "^6.1.2", "react-dom": "^19.2.0", "semver": "^7.7.2", "strip-ansi": "^7.1.2", "ts-prune": "^0.10.3", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", "yargs": "^17.7.2" }, "dependencies": { "ink": "npm:@jrichman/ink@6.4.11", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", "punycode": "^2.3.1", "simple-git": "^3.28.0" }, "optionalDependencies": { "@lydell/node-pty": "1.1.0", "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", "keytar": "^7.9.0", "node-pty": "^1.0.0" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "prettier --write", "eslint --fix --max-warnings 0 --no-warn-ignored" ], "eslint.config.js": [ "prettier --write" ], "*.{json,md}": [ "prettier --write" ] } } ================================================ FILE: packages/a2a-server/GEMINI.md ================================================ # Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`) Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities over HTTP for inter-agent communication. ## Architecture - `src/agent/`: Agent session management for A2A interactions. - `src/commands/`: CLI command definitions for the A2A server binary. - `src/config/`: Server configuration. - `src/http/`: HTTP server and route handlers. - `src/persistence/`: Session and state persistence. - `src/utils/`: Shared utility functions. - `src/types.ts`: Shared type definitions. ## Running - Binary entry point: `gemini-cli-a2a-server` ## Testing - Run tests: `npm test -w @google/gemini-cli-a2a-server` ================================================ FILE: packages/a2a-server/README.md ================================================ # Gemini CLI A2A Server ## All code in this package is experimental and under active development This package contains the A2A server implementation for the Gemini CLI. ================================================ FILE: packages/a2a-server/development-extension-rfc.md ================================================ # RFC: Gemini CLI A2A Development-Tool Extension ## 1. Introduction ### 1.1 Overview To standardize client integrations with the Gemini CLI agent, this document proposes the `development-tool` extension for the A2A protocol. Rather than creating a new protocol, this specification builds upon the existing A2A protocol. As an open-source standard recently adopted by the Linux Foundation, A2A provides a robust foundation for core concepts like tasks, messages, and streaming events. This extension-based approach allows us to leverage A2A's proven architecture while defining the specific capabilities required for rich, interactive workflows with the Gemini CLI agent. ### 1.2 Motivation Recent work integrating Gemini CLI with clients like Zed and Gemini Code Assist’s agent mode has highlighted the need for a robust, standard communication protocol. Standardizing on A2A provides several key advantages: - **Solid Foundation**: Provides a robust, open standard that ensures a stable, predictable, and consistent integration experience across different IDEs and client surfaces. - **Extensibility**: Creates a flexible foundation to support new tools and workflows as they emerge. - **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard, fostering broader interoperability. ## 2. Communication Flow The interaction follows A2A’s task-based, streaming pattern. The client sends a `message/stream` request and the agent responds with a `contextId` / `taskId` and a stream of events. `TaskStatusUpdateEvent` events are used to convey the overall state of the task. The task is complete when the agent sends a final `TaskStatusUpdateEvent` with `final: true` and a terminal status like `completed` or `failed`. ### 2.1 Asynchronous Responses and Notifications Clients that may disconnect from the agent should supply a `PushNotificationConfig` to the agent with the initial `message/stream` method or subsequently with the `tasks/pushNotificationConfig/set` method so that the agent can call back when updates are ready. ## 3. The `development-tool` extension ### 3.1 Overview The `development-tool` extension establishes a communication contract for workflows between a client and the Gemini CLI agent. It consists of a specialized set of schemas, embedded within core A2A data structures, that enable the agent to stream real-time updates on its state and thought process. These schemas also provide the mechanism for the agent to request user permission before executing tools. **Sample Agent Card** ```json { "name": "Gemini CLI Agent", "description": "An agent that generates code based on natural language instructions.", "capabilities": { "streaming": true, "extensions": [ { "uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md", "description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.", "required": true } ] } } ``` **Versioning** The agent card `uri` field contains an embedded semantic version. The client must extract this version to determine compatibility with the agent extension using the compatibility logic defined in Semantic Versioning 2.0.0 spec. ### 3.2 Schema Definitions This section defines the schemas for the `development-tool` A2A extension, organized by their function within the communication flow. Note that all custom objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed by the unique URI that points to that extension’s spec to prevent naming collisions with other extensions. **Initialization & Configuration** The first message in a session must contain an `AgentSettings` object in its metadata. This object provides the agent with the necessary configuration information for proper initialization. Additional configuration settings (ex. MCP servers, allowed tools, etc.) can be added to this message. **Schema** ```proto syntax = "proto3"; // Configuration settings for the Gemini CLI agent. message AgentSettings { // The absolute path to the workspace directory where the agent will execute. string workspace_path = 1; } ``` **Agent-to-Client Messages** All real-time updates from the agent (including its thoughts, tool calls, and simple text replies) are streamed to the client as `TaskStatusUpdateEvents`. Each Event contains a `Message` object, which holds the content in one of two formats: - **TextPart**: Used for standard text messages. This part requires no custom schema. - **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts are sent this way, each using their respective schemas defined below. **Tool Calls** The `ToolCall` schema is designed to provide a structured representation of a tool’s execution lifecycle. This protocol defines a clear state machine and provides detailed schemas for common development tasks (file edits, shell commands, MCP Tool), ensuring clients can build reliable UIs without being tied to a specific agent implementation. The core principle is that the agent sends a `ToolCall` object on every update. This makes client-side logic stateless and simple. **Tool Call Lifecycle** 1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If user permission is required, the `confirmation_request` field will be populated. 2. **Confirmation**: If the client needs to confirm the message, the client will send a `ToolCallConfirmation`. If the client responds with a cancellation, execution will be skipped. 3. **Execution**: Once approved (or if no approval is required), the agent sends an update with `status: EXECUTING`. It can stream real-time progress by updating the `live_content` field. 4. **Completion**: The agent sends a final update with the status set to `SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result field. **Schema** ```proto syntax = "proto3"; import "google/protobuf/struct.proto"; // ToolCall is the central message representing a tool's execution lifecycle. // The entire object is sent from the agent to client on every update. message ToolCall { // A unique identifier, assigned by the agent string tool_call_id = 1; // The current state of the tool call in its lifecycle ToolCallStatus status = 2; // Name of the tool being called (e.g. 'Edit', 'ShellTool') string tool_name = 3; // An optional description of the tool call's purpose to show the user optional string description = 4; // The structured input params provided by the LLM for tool invocation. google.protobuf.Struct input_parameters = 5; // String containing the real-time output from the tool as it executes (primarily designed for shell output). // During streaming the entire string is replaced on each update optional string live_content = 6; // The final result of the tool (used to replace live_content when applicable) oneof result { // The output on tool success ToolOutput output = 7; // The error details if the tool failed ErrorDetails error = 8; } // If the tool requires user confirmation, this field will be populated while status is PENDING optional ConfirmationRequest confirmation_request = 9; } // Possible execution status of a ToolCall enum ToolCallStatus { STATUS_UNSPECIFIED = 0; PENDING = 1; EXECUTING = 2; SUCCEEDED = 3; FAILED = 4; CANCELLED = 5; } // ToolOutput represents the final, successful, output of a tool message ToolOutput { oneof result { string text = 1; // For ToolCalls which resulted in a file modification FileDiff diff = 2; // A generic fallback for any other structured JSON data google.protobuf.Struct structured_data = 3; } } // A structured representation of an error message ErrorDetails { // User facing error message string message = 1; // Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error) optional string type = 2; // Optional status code optional int32 status_code = 3; } // ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall message ConfirmationRequest { // A list of choices for the user to select from repeated ConfirmationOption options = 1; // Specific details of the action requiring user confirmation oneof details { ExecuteDetails execute_details = 2; FileDiff file_edit_details = 3; McpDetails mcp_details = 4; GenericDetails generic_details = 5; } } // A single choice presented to the user during a confirmation request message ConfirmationOption { // Unique ID for the choice (e.g. proceed_once, cancel) string id = 1; // Human-readable choice (e.g. Allow Once, Reject). string name = 2; // An optional longer description for a tooltip optional string description = 3; } // Details for a request to execute a shell command message ExecuteDetails { // The shell command to be executed string command = 1; // An optional directory in which the command will be run optional string working_directory = 2; } message FileDiff { string file_name = 1; // The absolute path to the file to modify string file_path = 2; // The original content, if the file exists optional string old_content = 3; string new_content = 4; // Pre-formatted diff string for display optional string formatted_diff = 5; } // Details for an MCP (Model Context Protocol) tool confirmation message McpDetails { // The name of the MCP server that provides the tool string server_name = 1; // THe name of the tool being called from the MCP Server string tool_name = 2; } // Generic catch-all for ToolCall requests that don't fit other types message GenericDetails { // Description of the action requiring confirmation string description = 1; } ``` **Agent Thoughts** **Schema** ```proto syntax = "proto3"; // Represents a thought with a subject and a detailed description. message AgentThought { // A concise subject line or title for the thought. string subject = 1; // The description or elaboration of the thought itself. string description = 2; } ``` **Event Metadata** The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to deserialize the `TaskStatusUpdateEvents` into their appropriate objects. **Schema** ```proto syntax = "proto3"; // A DevelopmentToolEvent event. message DevelopmentToolEvent { // Enum representing the specific type of development tool event. enum DevelopmentToolEventKind { // The default, unspecified value. DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0; TOOL_CALL_CONFIRMATION = 1; TOOL_CALL_UPDATE = 2; TEXT_CONTENT = 3; STATE_CHANGE = 4; THOUGHT = 5; } // The specific kind of event that occurred. DevelopmentToolEventKind kind = 1; // The model used for this event. string model = 2; // The tier of the user (optional). string user_tier = 3; // An unexpected error occurred in the agent execution (optional). string error = 4; } ``` **Client-to-Agent Messages** When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to `input-required` and its message contains a `ConfirmationRequest`, the client must respond by sending a new `message/stream` request. This new request must include the `contextId` and the `taskId` from the ongoing task and contain a `ToolCallConfirmation` object. This object conveys the user's decision regarding the tool call that was awaiting approval. **Schema** ```proto syntax = "proto3"; // The client's response to a ConfirmationRequest. message ToolCallConfirmation { // A unique identifier, assigned by the agent string tool_call_id = 1; // The 'id' of the ConfirmationOption chosen by the user. string selected_option_id = 2; // Included if the user modifies the proposed change. // The type should correspond to the original ConfirmationRequest details. oneof modified_details { // Corresponds to a FileDiff confirmation ModifiedFileDetails file_details = 3; } } message ModifiedFileDetails { // The new content after user edits. string new_content = 1; } ``` ### 3.3 Method Definitions This section defines the new methods introduced by the `development-tool` extension. **Method: `commands/get`** This method allows the client to discover slash commands supported by Gemini CLI. The client should call this method during startup to dynamically populate its command list. ```proto // Response message containing the list of all top-level slash commands. message GetAllSlashCommandsResponse { // A list of the top-level slash commands. repeated SlashCommand commands = 1; } // Represents a single slash command, which can contain subcommands. message SlashCommand { // The primary name of the command. string name = 1; // A detailed description of what the command does. string description = 2; // A list of arguments that the command accepts. repeated SlashCommandArgument arguments = 3; // A list of nested subcommands. repeated SlashCommand sub_commands = 4; } // Defines the structure for a single slash command argument. message SlashCommandArgument { // The name of the argument. string name = 1; // A brief description of what the argument is for. string description = 2; // Whether the argument is required or optional. bool is_required = 3; } ``` **Method: `command/execute`** This method allows the client to execute a slash command. Following the initial `ExecuteSlashCommandResponse`, the agent will use the standard streaming mechanism to communicate the command's progress and output. All subsequent updates, including textual output, agent thoughts, and any required user confirmations for tool calls (like executing a shell command), will be sent as `TaskStatusUpdateEvent` messages, re-using the schemas defined above. ```proto // Request to execute a specific slash command. message ExecuteSlashCommandRequest { // The path to the command, e.g., ["memory", "add"] for /memory add repeated string command_path = 1; // The arguments for the command as a single string. string args = 2; } // Enum for the initial status of a command execution request. enum CommandExecutionStatus { // Default unspecified status. COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0; // The command was successfully received and its execution has started. STARTED = 1; // The command failed to start (e.g., command not found, invalid format). FAILED_TO_START = 2; // The command has been paused and is waiting for the user to confirm // a set of shell commands. AWAITING_SHELL_CONFIRMATION = 3; // The command has been paused and is waiting for the user to confirm // a specific action. AWAITING_ACTION_CONFIRMATION = 4; } // The immediate, async response after requesting a command execution. message ExecuteSlashCommandResponse { // A unique taskID for this specific command execution. string execution_id = 1; // The initial status of the command execution. CommandExecutionStatus status = 2; // An optional message, particularly useful for explaining why a command // failed to start. string message = 3; } ``` ## 4. Separation of Concerns We believe that all client-side context (ex., workspace state) and client-side tool execution (ex. read active buffers) should be routed through MCP. This approach enforces a strict separation of concerns: the A2A `development-tool` extension standardizes communication to the agent, while MCP serves as the single, authoritative interface for client-side capabilities. ## Appendix ### A. Example Interaction Flow 1. **Client -> Server**: The client sends a `message/stream` request containing the initial prompt and configuration in an `AgentSettings` object. 2. **Server -> Client**: SSE stream begins. - **Event 1**: The server sends a `Task` object with `status.state: 'submitted'` and the new `taskId`. - **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata `kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`. 3. **Agent Logic**: The agent processes the prompt and decides to call the `write_file` tool, which requires user confirmation. 4. **Server -> Client**: - **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata `kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall` object with its `status` as `'PENDING'` and a populated `confirmation_request`. - **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is `'input-required'`, and `final` is `true`. The stream for this request ends. 5. **Client**: The client UI renders the confirmation prompt based on the `ToolCall` object from Event 3. The user clicks "Approve." 6. **Client -> Server**: The client sends a new `message/stream` request. It includes the `taskId` from the ongoing task and a `DataPart` containing a `ToolCallConfirmation` object (e.g., `{"tool_call_id": "...", "selected_option_id": "proceed_once"}`). 7. **Server -> Client**: A new SSE stream begins for the second request. - **Event 1**: The server sends a `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its `status` now set to `'EXECUTING'`. - **Event 2**: After the tool runs, the server sends another `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` with its `status` as `'SUCCEEDED'`. 8. **Agent Logic**: The agent receives the successful tool result and generates a final textual response. 9. **Server -> Client**: - **Event 3**: The server sends a `TaskStatusUpdateEvent` with `kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final answer. - **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The `kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and `final` is `true`. The stream ends. 10. **Client**: The client displays the final answer. The task is now complete but can be continued by sending another message with the same `taskId`. ================================================ FILE: packages/a2a-server/index.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export * from './src/index.js'; ================================================ FILE: packages/a2a-server/package.json ================================================ { "name": "@google/gemini-cli-a2a-server", "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI A2A Server", "repository": { "type": "git", "url": "git+https://github.com/google-gemini/gemini-cli.git", "directory": "packages/a2a-server" }, "type": "module", "main": "dist/index.js", "bin": { "gemini-cli-a2a-server": "dist/a2a-server.mjs" }, "scripts": { "build": "node ../../scripts/build_package.js", "start": "node dist/src/http/server.js", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run --coverage", "typecheck": "tsc --noEmit" }, "files": [ "dist" ], "dependencies": { "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", "fs-extra": "^11.3.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.8", "uuid": "^13.0.0", "winston": "^3.17.0" }, "devDependencies": { "@google/genai": "1.30.0", "@types/express": "^5.0.3", "@types/fs-extra": "^11.0.4", "@types/supertest": "^6.0.3", "@types/tar": "^6.1.13", "dotenv": "^16.4.5", "supertest": "^7.1.4", "typescript": "^5.3.3", "vitest": "^3.1.1" }, "engines": { "node": ">=20" } } ================================================ FILE: packages/a2a-server/src/agent/executor.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { CoderAgentExecutor } from './executor.js'; import type { ExecutionEventBus, RequestContext, TaskStore, } from '@a2a-js/sdk/server'; import { EventEmitter } from 'node:events'; import { requestStorage } from '../http/requestStorage.js'; // Mocks for constructor dependencies vi.mock('../config/config.js', () => ({ loadConfig: vi.fn().mockReturnValue({ getSessionId: () => 'test-session', getTargetDir: () => '/tmp', getCheckpointingEnabled: () => false, }), loadEnvironment: vi.fn(), setTargetDir: vi.fn().mockReturnValue('/tmp'), })); vi.mock('../config/settings.js', () => ({ loadSettings: vi.fn().mockReturnValue({}), })); vi.mock('../config/extension.js', () => ({ loadExtensions: vi.fn().mockReturnValue([]), })); vi.mock('../http/requestStorage.js', () => ({ requestStorage: { getStore: vi.fn(), }, })); vi.mock('./task.js', () => { const mockTaskInstance = (taskId: string, contextId: string) => ({ id: taskId, contextId, taskState: 'working', acceptUserMessage: vi .fn() .mockImplementation(async function* (context, aborted) { const isConfirmation = ( context.userMessage.parts as Array<{ kind: string }> ).some((p) => p.kind === 'confirmation'); // Hang only for main user messages (text), allow confirmations to finish quickly if (!isConfirmation && aborted) { await new Promise((resolve) => { aborted.addEventListener('abort', resolve, { once: true }); }); } yield { type: 'content', value: 'hello' }; }), acceptAgentMessage: vi.fn().mockResolvedValue(undefined), scheduleToolCalls: vi.fn().mockResolvedValue(undefined), waitForPendingTools: vi.fn().mockResolvedValue(undefined), getAndClearCompletedTools: vi.fn().mockReturnValue([]), addToolResponsesToHistory: vi.fn(), sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}), cancelPendingTools: vi.fn(), setTaskStateAndPublishUpdate: vi.fn(), dispose: vi.fn(), getMetadata: vi.fn().mockResolvedValue({}), geminiClient: { initialize: vi.fn().mockResolvedValue(undefined), }, toSDKTask: () => ({ id: taskId, contextId, kind: 'task', status: { state: 'working', timestamp: new Date().toISOString() }, metadata: {}, history: [], artifacts: [], }), }); const MockTask = vi.fn().mockImplementation(mockTaskInstance); (MockTask as unknown as { create: Mock }).create = vi .fn() .mockImplementation(async (taskId: string, contextId: string) => mockTaskInstance(taskId, contextId), ); return { Task: MockTask }; }); describe('CoderAgentExecutor', () => { let executor: CoderAgentExecutor; let mockTaskStore: TaskStore; let mockEventBus: ExecutionEventBus; beforeEach(() => { vi.clearAllMocks(); mockTaskStore = { save: vi.fn().mockResolvedValue(undefined), load: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue([]), } as unknown as TaskStore; mockEventBus = new EventEmitter() as unknown as ExecutionEventBus; mockEventBus.publish = vi.fn(); mockEventBus.finished = vi.fn(); executor = new CoderAgentExecutor(mockTaskStore); }); it('should distinguish between primary and secondary execution', async () => { const taskId = 'test-task'; const contextId = 'test-context'; const mockSocket = new EventEmitter(); const requestContext = { userMessage: { messageId: 'msg-1', taskId, contextId, parts: [{ kind: 'text', text: 'hi' }], metadata: { coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, }, }, } as unknown as RequestContext; // Mock requestStorage for primary (requestStorage.getStore as Mock).mockReturnValue({ req: { socket: mockSocket }, }); // First execution (Primary) const primaryPromise = executor.execute(requestContext, mockEventBus); // Give it enough time to reach line 490 in executor.ts await new Promise((resolve) => setTimeout(resolve, 50)); expect( ( executor as unknown as { executingTasks: Set } ).executingTasks.has(taskId), ).toBe(true); const wrapper = executor.getTask(taskId); expect(wrapper).toBeDefined(); // Mock requestStorage for secondary const secondarySocket = new EventEmitter(); (requestStorage.getStore as Mock).mockReturnValue({ req: { socket: secondarySocket }, }); const secondaryRequestContext = { userMessage: { messageId: 'msg-2', taskId, contextId, parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }], metadata: { coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, }, }, } as unknown as RequestContext; const secondaryPromise = executor.execute( secondaryRequestContext, mockEventBus, ); // Secondary execution should NOT add to executingTasks (already there) // and should return early after its loop await secondaryPromise; // Task should still be in executingTasks and NOT disposed expect( ( executor as unknown as { executingTasks: Set } ).executingTasks.has(taskId), ).toBe(true); expect(wrapper?.task.dispose).not.toHaveBeenCalled(); // Now simulate secondary socket closure - it should NOT affect primary secondarySocket.emit('end'); expect( ( executor as unknown as { executingTasks: Set } ).executingTasks.has(taskId), ).toBe(true); expect(wrapper?.task.dispose).not.toHaveBeenCalled(); // Set to terminal state to verify disposal on finish wrapper!.task.taskState = 'completed'; // Now close primary socket mockSocket.emit('end'); await primaryPromise; expect( ( executor as unknown as { executingTasks: Set } ).executingTasks.has(taskId), ).toBe(false); expect(wrapper?.task.dispose).toHaveBeenCalled(); }); it('should evict task from cache when it reaches terminal state', async () => { const taskId = 'test-task-terminal'; const contextId = 'test-context'; const mockSocket = new EventEmitter(); (requestStorage.getStore as Mock).mockReturnValue({ req: { socket: mockSocket }, }); const requestContext = { userMessage: { messageId: 'msg-1', taskId, contextId, parts: [{ kind: 'text', text: 'hi' }], metadata: { coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, }, }, } as unknown as RequestContext; const primaryPromise = executor.execute(requestContext, mockEventBus); await new Promise((resolve) => setTimeout(resolve, 50)); const wrapper = executor.getTask(taskId)!; expect(wrapper).toBeDefined(); // Simulate terminal state wrapper.task.taskState = 'completed'; // Finish primary execution mockSocket.emit('end'); await primaryPromise; expect(executor.getTask(taskId)).toBeUndefined(); expect(wrapper.task.dispose).toHaveBeenCalled(); }); }); ================================================ FILE: packages/a2a-server/src/agent/executor.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Message, Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore, AgentExecutor, AgentExecutionEvent, RequestContext, ExecutionEventBus, } from '@a2a-js/sdk/server'; import { GeminiEventType, SimpleExtensionLoader, type ToolCallRequestInfo, type Config, } from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import { CoderAgentEvent, getPersistedState, setPersistedState, type StateChange, type AgentSettings, type PersistedStateMetadata, getContextIdFromMetadata, getAgentSettingsFromMetadata, } from '../types.js'; import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { Task } from './task.js'; import { requestStorage } from '../http/requestStorage.js'; import { pushTaskStateFailed } from '../utils/executor_utils.js'; /** * Provides a wrapper for Task. Passes data from Task to SDKTask. * The idea is to use this class inside CoderAgentExecutor to replace Task. */ class TaskWrapper { task: Task; agentSettings: AgentSettings; constructor(task: Task, agentSettings: AgentSettings) { this.task = task; this.agentSettings = agentSettings; } get id() { return this.task.id; } toSDKTask(): SDKTask { const persistedState: PersistedStateMetadata = { _agentSettings: this.agentSettings, _taskState: this.task.taskState, }; const sdkTask: SDKTask = { id: this.task.id, contextId: this.task.contextId, kind: 'task', status: { state: this.task.taskState, timestamp: new Date().toISOString(), }, metadata: setPersistedState({}, persistedState), history: [], artifacts: [], }; sdkTask.metadata!['_contextId'] = this.task.contextId; return sdkTask; } } /** * CoderAgentExecutor implements the agent's core logic for code generation. */ export class CoderAgentExecutor implements AgentExecutor { private tasks: Map = new Map(); // Track tasks with an active execution loop. private executingTasks = new Set(); constructor(private taskStore?: TaskStore) {} private async getConfig( agentSettings: AgentSettings, taskId: string, ): Promise { const workspaceRoot = setTargetDir(agentSettings); loadEnvironment(); // Will override any global env with workspace envs const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId); } /** * Reconstructs TaskWrapper from SDKTask. */ async reconstruct( sdkTask: SDKTask, eventBus?: ExecutionEventBus, ): Promise { const metadata = sdkTask.metadata || {}; const persistedState = getPersistedState(metadata); if (!persistedState) { throw new Error( `Cannot reconstruct task ${sdkTask.id}: missing persisted state in metadata.`, ); } const agentSettings = persistedState._agentSettings; const config = await this.getConfig(agentSettings, sdkTask.id); const contextId: string = getContextIdFromMetadata(metadata) || sdkTask.contextId; const runtimeTask = await Task.create( sdkTask.id, contextId, config, eventBus, agentSettings.autoExecute, ); runtimeTask.taskState = persistedState._taskState; await runtimeTask.geminiClient.initialize(); const wrapper = new TaskWrapper(runtimeTask, agentSettings); this.tasks.set(sdkTask.id, wrapper); logger.info(`Task ${sdkTask.id} reconstructed from store.`); return wrapper; } async createTask( taskId: string, contextId: string, agentSettingsInput?: AgentSettings, eventBus?: ExecutionEventBus, ): Promise { const agentSettings: AgentSettings = agentSettingsInput || { kind: CoderAgentEvent.StateAgentSettingsEvent, workspacePath: process.cwd(), }; const config = await this.getConfig(agentSettings, taskId); const runtimeTask = await Task.create( taskId, contextId, config, eventBus, agentSettings.autoExecute, ); await runtimeTask.geminiClient.initialize(); const wrapper = new TaskWrapper(runtimeTask, agentSettings); this.tasks.set(taskId, wrapper); logger.info(`New task ${taskId} created.`); return wrapper; } getTask(taskId: string): TaskWrapper | undefined { return this.tasks.get(taskId); } getAllTasks(): TaskWrapper[] { return Array.from(this.tasks.values()); } cancelTask = async ( taskId: string, eventBus: ExecutionEventBus, ): Promise => { logger.info( `[CoderAgentExecutor] Received cancel request for task ${taskId}`, ); const wrapper = this.tasks.get(taskId); if (!wrapper) { logger.warn( `[CoderAgentExecutor] Task ${taskId} not found for cancellation.`, ); eventBus.publish({ kind: 'status-update', taskId, contextId: uuidv4(), status: { state: 'failed', message: { kind: 'message', role: 'agent', parts: [{ kind: 'text', text: `Task ${taskId} not found.` }], messageId: uuidv4(), taskId, }, }, final: true, }); return; } const { task } = wrapper; if (task.taskState === 'canceled' || task.taskState === 'failed') { logger.info( `[CoderAgentExecutor] Task ${taskId} is already in a final state: ${task.taskState}. No action needed for cancellation.`, ); eventBus.publish({ kind: 'status-update', taskId, contextId: task.contextId, status: { state: task.taskState, message: { kind: 'message', role: 'agent', parts: [ { kind: 'text', text: `Task ${taskId} is already ${task.taskState}.`, }, ], messageId: uuidv4(), taskId, }, }, final: true, }); return; } try { logger.info( `[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`, ); task.cancelPendingTools('Task canceled by user request.'); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; task.setTaskStateAndPublishUpdate( 'canceled', stateChange, 'Task canceled by user request.', undefined, true, ); logger.info( `[CoderAgentExecutor] Task ${taskId} cancellation processed. Saving state.`, ); await this.taskStore?.save(wrapper.toSDKTask()); logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`); // Cleanup listener subscriptions to avoid memory leaks. wrapper.task.dispose(); this.tasks.delete(taskId); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error( `[CoderAgentExecutor] Error during task cancellation for ${taskId}: ${errorMessage}`, error, ); eventBus.publish({ kind: 'status-update', taskId, contextId: task.contextId, status: { state: 'failed', message: { kind: 'message', role: 'agent', parts: [ { kind: 'text', text: `Failed to process cancellation for task ${taskId}: ${errorMessage}`, }, ], messageId: uuidv4(), taskId, }, }, final: true, }); } }; async execute( requestContext: RequestContext, eventBus: ExecutionEventBus, ): Promise { const userMessage = requestContext.userMessage; const sdkTask = requestContext.task; const taskId = sdkTask?.id || userMessage.taskId || uuidv4(); const contextId: string = userMessage.contextId || sdkTask?.contextId || getContextIdFromMetadata(sdkTask?.metadata) || uuidv4(); logger.info( `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`, ); logger.info( `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`, ); eventBus.on('event', (event: AgentExecutionEvent) => logger.info('[EventBus event]: ', event), ); const store = requestStorage.getStore(); if (!store) { logger.error( '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.', ); } const abortController = new AbortController(); const abortSignal = abortController.signal; if (store) { // Grab the raw socket from the request object const socket = store.req.socket; const onSocketEnd = () => { logger.info( `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`, ); if (!abortController.signal.aborted) { abortController.abort(); } // Clean up the listener to prevent memory leaks socket.removeListener('end', onSocketEnd); }; // Listen on the socket's 'end' event (remote closed the connection) socket.on('end', onSocketEnd); socket.once('close', () => { socket.removeListener('end', onSocketEnd); }); // It's also good practice to remove the listener if the task completes successfully abortSignal.addEventListener('abort', () => { socket.removeListener('end', onSocketEnd); }); logger.info( `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`, ); } let wrapper: TaskWrapper | undefined = this.tasks.get(taskId); if (wrapper) { wrapper.task.eventBus = eventBus; logger.info(`[CoderAgentExecutor] Task ${taskId} found in memory cache.`); } else if (sdkTask) { logger.info( `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`, ); try { wrapper = await this.reconstruct(sdkTask, eventBus); } catch (e) { logger.error( `[CoderAgentExecutor] Failed to hydrate task ${taskId}:`, e, ); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; eventBus.publish({ kind: 'status-update', taskId, contextId: sdkTask.contextId, status: { state: 'failed', message: { kind: 'message', role: 'agent', parts: [ { kind: 'text', text: 'Internal error: Task state lost or corrupted.', }, ], messageId: uuidv4(), taskId, contextId: sdkTask.contextId, } as Message, }, final: true, metadata: { coderAgent: stateChange }, }); return; } } else { logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`); const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata); try { wrapper = await this.createTask( taskId, contextId, agentSettings, eventBus, ); } catch (error) { logger.error( `[CoderAgentExecutor] Error creating task ${taskId}:`, error, ); // eslint-disable-next-line @typescript-eslint/no-floating-promises pushTaskStateFailed(error, eventBus, taskId, contextId); return; } const newTaskSDK = wrapper.toSDKTask(); eventBus.publish({ ...newTaskSDK, kind: 'task', status: { state: 'submitted', timestamp: new Date().toISOString() }, history: [userMessage], }); try { await this.taskStore?.save(newTaskSDK); logger.info(`[CoderAgentExecutor] New task ${taskId} saved to store.`); } catch (saveError) { logger.error( `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`, saveError, ); } } if (!wrapper) { logger.error( `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`, ); return; } const currentTask = wrapper.task; if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) { logger.warn( `[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`, ); return; } if (this.executingTasks.has(taskId)) { logger.info( `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`, ); currentTask.eventBus = eventBus; for await (const _ of currentTask.acceptUserMessage( requestContext, abortController.signal, )) { logger.info( `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, ); } // End this execution-- the original/source will be resumed. return; } // Check if this is the primary/initial execution for this task const isPrimaryExecution = !this.executingTasks.has(taskId); if (!isPrimaryExecution) { logger.info( `[CoderAgentExecutor] Primary execution already active for task ${taskId}. Starting secondary loop for message ${userMessage.messageId}.`, ); currentTask.eventBus = eventBus; for await (const _ of currentTask.acceptUserMessage( requestContext, abortController.signal, )) { logger.info( `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, ); } // End this execution-- the original/source will be resumed. return; } logger.info( `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`, ); this.executingTasks.add(taskId); try { let agentTurnActive = true; logger.info(`[CoderAgentExecutor] Task ${taskId}: Processing user turn.`); let agentEvents = currentTask.acceptUserMessage( requestContext, abortSignal, ); while (agentTurnActive) { logger.info( `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`, ); const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of agentEvents) { if (abortSignal.aborted) { logger.warn( `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`, ); throw new Error('Execution aborted'); } if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); continue; } await currentTask.acceptAgentMessage(event); } if (abortSignal.aborted) throw new Error('Execution aborted'); if (toolCallRequests.length > 0) { logger.info( `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`, ); await currentTask.scheduleToolCalls(toolCallRequests, abortSignal); } logger.info( `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`, ); await currentTask.waitForPendingTools(); logger.info( `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`, ); if (abortSignal.aborted) throw new Error('Execution aborted'); const completedTools = currentTask.getAndClearCompletedTools(); if (completedTools.length > 0) { // If all completed tool calls were canceled, manually add them to history and set state to input-required, final:true if (completedTools.every((tool) => tool.status === 'cancelled')) { logger.info( `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`, ); currentTask.addToolResponsesToHistory(completedTools); agentTurnActive = false; const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; currentTask.setTaskStateAndPublishUpdate( 'input-required', stateChange, undefined, undefined, true, ); } else { logger.info( `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`, ); agentEvents = currentTask.sendCompletedToolsToLlm( completedTools, abortSignal, ); // Continue the loop to process the LLM response to the tool results. } } else { logger.info( `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`, ); agentTurnActive = false; } } logger.info( `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`, ); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; currentTask.setTaskStateAndPublishUpdate( 'input-required', stateChange, undefined, undefined, true, ); } catch (error) { if (abortSignal.aborted) { logger.warn(`[CoderAgentExecutor] Task ${taskId} execution aborted.`); currentTask.cancelPendingTools('Execution aborted'); if ( currentTask.taskState !== 'canceled' && currentTask.taskState !== 'failed' ) { currentTask.setTaskStateAndPublishUpdate( 'input-required', { kind: CoderAgentEvent.StateChangeEvent }, 'Execution aborted by client.', undefined, true, ); } } else { const errorMessage = error instanceof Error ? error.message : 'Agent execution error'; logger.error( `[CoderAgentExecutor] Error executing agent for task ${taskId}:`, error, ); currentTask.cancelPendingTools(errorMessage); if (currentTask.taskState !== 'failed') { const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; currentTask.setTaskStateAndPublishUpdate( 'failed', stateChange, errorMessage, undefined, true, ); } } } finally { if (isPrimaryExecution) { this.executingTasks.delete(taskId); logger.info( `[CoderAgentExecutor] Saving final state for task ${taskId}.`, ); try { await this.taskStore?.save(wrapper.toSDKTask()); logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); } catch (saveError) { logger.error( `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, saveError, ); } if ( ['canceled', 'failed', 'completed'].includes(currentTask.taskState) ) { logger.info( `[CoderAgentExecutor] Task ${taskId} reached terminal state ${currentTask.taskState}. Evicting and disposing.`, ); wrapper.task.dispose(); this.tasks.delete(taskId); } } } } } ================================================ FILE: packages/a2a-server/src/agent/task-event-driven.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Task } from './task.js'; import { type Config, MessageBusType, ToolConfirmationOutcome, ApprovalMode, Scheduler, type MessageBus, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; describe('Task Event-Driven Scheduler', () => { let mockConfig: Config; let mockEventBus: ExecutionEventBus; let messageBus: MessageBus; beforeEach(() => { vi.clearAllMocks(); mockConfig = createMockConfig({ isEventDrivenSchedulerEnabled: () => true, }) as Config; messageBus = mockConfig.messageBus; mockEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; }); it('should instantiate Scheduler when enabled', () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); expect(task.scheduler).toBeInstanceOf(Scheduler); }); it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => { // @ts-expect-error - Calling private constructor // eslint-disable-next-line @typescript-eslint/no-unused-vars const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'ls', args: {} }, status: 'executing', }; // Simulate MessageBus event // Simulate MessageBus event const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; if (!handler) { throw new Error('TOOL_CALLS_UPDATE handler not found'); } handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall], }); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ status: expect.objectContaining({ state: 'submitted', // initial task state }), metadata: expect.objectContaining({ coderAgent: expect.objectContaining({ kind: 'tool-call-update', }), }), }), ); }); it('should handle tool confirmations by publishing to MessageBus', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'ls', args: {} }, status: 'awaiting_approval', correlationId: 'corr-1', confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, }; // Simulate MessageBus event to stash the correlationId // Simulate MessageBus event const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; if (!handler) { throw new Error('TOOL_CALLS_UPDATE handler not found'); } handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall], }); // Simulate A2A client confirmation const part = { kind: 'data', data: { callId: '1', outcome: 'proceed_once', }, }; const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart(part); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-1', confirmed: true, outcome: ToolConfirmationOutcome.ProceedOnce, }), ); }); it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'ls', args: {} }, status: 'awaiting_approval', correlationId: 'corr-1', confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); // Simulate Rejection (Cancel) const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'cancel' }, }); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-1', confirmed: false, }), ); const toolCall2 = { request: { callId: '2', name: 'ls', args: {} }, status: 'awaiting_approval', correlationId: 'corr-2', confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, }; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] }); // Simulate ModifyWithEditor const handled2 = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '2', outcome: 'modify_with_editor' }, }); expect(handled2).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-2', confirmed: false, outcome: ToolConfirmationOutcome.ModifyWithEditor, payload: undefined, }), ); }); it('should handle MCP Server tool operations correctly', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'call_mcp_tool', args: {} }, status: 'awaiting_approval', correlationId: 'corr-mcp-1', confirmationDetails: { type: 'mcp', title: 'MCP Server Operation', prompt: 'test_mcp', }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); // Simulate ProceedOnce for MCP const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'proceed_once' }, }); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-mcp-1', confirmed: true, outcome: ToolConfirmationOutcome.ProceedOnce, }), ); }); it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'call_mcp_tool', args: {} }, status: 'awaiting_approval', correlationId: 'corr-mcp-2', confirmationDetails: { type: 'mcp', title: 'MCP Server Operation', prompt: 'test_mcp', }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'proceed_always_server' }, }); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-mcp-2', confirmed: true, outcome: ToolConfirmationOutcome.ProceedAlwaysServer, }), ); }); it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'call_mcp_tool', args: {} }, status: 'awaiting_approval', correlationId: 'corr-mcp-3', confirmationDetails: { type: 'mcp', title: 'MCP Server Operation', prompt: 'test_mcp', }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'proceed_always_tool' }, }); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-mcp-3', confirmed: true, outcome: ToolConfirmationOutcome.ProceedAlwaysTool, }), ); }); it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'call_mcp_tool', args: {} }, status: 'awaiting_approval', correlationId: 'corr-mcp-4', confirmationDetails: { type: 'mcp', title: 'MCP Server Operation', prompt: 'test_mcp', }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'proceed_always_and_save' }, }); expect(handled).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-mcp-4', confirmed: true, outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave, }), ); }); it('should execute without confirmation in YOLO mode and not transition to input-required', async () => { // Enable YOLO mode const yoloConfig = createMockConfig({ isEventDrivenSchedulerEnabled: () => true, getApprovalMode: () => ApprovalMode.YOLO, }) as Config; const yoloMessageBus = yoloConfig.messageBus; // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus); task.setTaskStateAndPublishUpdate = vi.fn(); const toolCall = { request: { callId: '1', name: 'ls', args: {} }, status: 'awaiting_approval', correlationId: 'corr-1', confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, }; const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); // Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly expect(yoloMessageBus.publish).not.toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, }), ); // Should NOT transition to input-required since it was auto-approved expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( 'input-required', expect.anything(), undefined, undefined, true, ); }); it('should handle output updates via the message bus', async () => { // @ts-expect-error - Calling private constructor // eslint-disable-next-line @typescript-eslint/no-unused-vars const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall = { request: { callId: '1', name: 'ls', args: {} }, status: 'executing', liveOutput: 'chunk1', }; // Simulate MessageBus event // Simulate MessageBus event const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; if (!handler) { throw new Error('TOOL_CALLS_UPDATE handler not found'); } handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall], }); // Should publish artifact update for output expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ kind: 'artifact-update', artifact: expect.objectContaining({ artifactId: 'tool-1-output', parts: [{ kind: 'text', text: 'chunk1' }], }), }), ); }); it('should complete artifact creation without hanging', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCallId = 'create-file-123'; task['_registerToolCall'](toolCallId, 'executing'); const toolCall = { request: { callId: toolCallId, name: 'writeFile', args: { path: 'test.sh' }, }, status: 'success', result: { ok: true }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); // The tool should be complete and registered appropriately, eventually // triggering the toolCompletionPromise resolution when all clear. const internalTask = task as unknown as { completedToolCalls: unknown[]; pendingToolCalls: Map; }; expect(internalTask.completedToolCalls.length).toBe(1); expect(internalTask.pendingToolCalls.size).toBe(0); }); it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); // Initialize the ID for the first turn (happens internally upon LLM stream) task.currentAgentMessageId = 'test-id-123'; // Simulate sending multiple text chunks task._sendTextContent('chunk 1'); task._sendTextContent('chunk 2'); // Both text contents should have been published with the same messageId const textCalls = (mockEventBus.publish as Mock).mock.calls.filter( (call) => call[0].status?.message?.kind === 'message', ); expect(textCalls.length).toBe(2); expect(textCalls[0][0].status.message.messageId).toBe('test-id-123'); expect(textCalls[1][0].status.message.messageId).toBe('test-id-123'); // Simulate starting a new turn by calling getAndClearCompletedTools // (which precedes sendCompletedToolsToLlm where a new ID is minted) task.getAndClearCompletedTools(); // sendCompletedToolsToLlm internally rolls the ID forward. // Simulate what sendCompletedToolsToLlm does: const internalTask = task as unknown as { setTaskStateAndPublishUpdate: (state: string, change: unknown) => void; }; internalTask.setTaskStateAndPublishUpdate('working', {}); // Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn task.currentAgentMessageId = 'test-id-456'; task._sendTextContent('chunk 3'); const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter( (call) => call[0].status?.message?.messageId === 'test-id-456', ); expect(secondTurnCalls.length).toBe(1); expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3'); }); it('should handle parallel tool calls correctly', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const toolCall1 = { request: { callId: '1', name: 'ls', args: {} }, status: 'awaiting_approval', correlationId: 'corr-1', confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' }, }; const toolCall2 = { request: { callId: '2', name: 'pwd', args: {} }, status: 'awaiting_approval', correlationId: 'corr-2', confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; // Publish update for both tool calls simultaneously handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall1, toolCall2], }); // Confirm first tool call const handled1 = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '1', outcome: 'proceed_once' }, }); expect(handled1).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-1', confirmed: true, }), ); // Confirm second tool call const handled2 = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: '2', outcome: 'cancel' }, }); expect(handled2).toBe(true); expect(messageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: 'corr-2', confirmed: false, }), ); }); it('should wait for executing tools before transitioning to input-required state', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); task.setTaskStateAndPublishUpdate = vi.fn(); // Register tool 1 as executing task['_registerToolCall']('1', 'executing'); const toolCall1 = { request: { callId: '1', name: 'ls', args: {} }, status: 'executing', }; const toolCall2 = { request: { callId: '2', name: 'pwd', args: {} }, status: 'awaiting_approval', correlationId: 'corr-2', confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, }; const handler = (messageBus.subscribe as Mock).mock.calls.find( (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, )?.[1]; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall1, toolCall2], }); // Should NOT transition to input-required yet expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( 'input-required', expect.anything(), undefined, undefined, true, ); // Complete tool 1 const toolCall1Complete = { ...toolCall1, status: 'success', result: { ok: true }, }; handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall1Complete, toolCall2], }); // Now it should transition expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith( 'input-required', expect.anything(), undefined, undefined, true, ); }); it('should ignore confirmations for unknown tool calls', async () => { // @ts-expect-error - Calling private constructor const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); const handled = await ( task as unknown as { _handleToolConfirmationPart: (part: unknown) => Promise; } )._handleToolConfirmationPart({ kind: 'data', data: { callId: 'unknown-id', outcome: 'proceed_once' }, }); // Should return false for unhandled tool call expect(handled).toBe(false); // Should not publish anything to the message bus expect(messageBus.publish).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/a2a-server/src/agent/task.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Task } from './task.js'; import { GeminiEventType, type Config, type ToolCallRequestInfo, type GitService, type CompletedToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; import { CoderAgentEvent } from '../types.js'; const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, processRestorableToolCalls: mockProcessRestorableToolCalls, }; }); describe('Task', () => { it('scheduleToolCalls should not modify the input requests array', async () => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // The Task constructor is private. We'll bypass it for this unit test. // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); task['setTaskStateAndPublishUpdate'] = vi.fn(); task['getProposedContent'] = vi.fn().mockResolvedValue('new content'); const requests: ToolCallRequestInfo[] = [ { callId: '1', name: 'replace', args: { file_path: 'test.txt', old_string: 'old', new_string: 'new', }, isClientInitiated: false, prompt_id: 'prompt-id-1', }, ]; const originalRequests = JSON.parse(JSON.stringify(requests)); const abortController = new AbortController(); await task.scheduleToolCalls(requests, abortController.signal); expect(requests).toEqual(originalRequests); }); describe('scheduleToolCalls', () => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); }); it('should not create a checkpoint if no restorable tools are called', async () => { // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const requests: ToolCallRequestInfo[] = [ { callId: '1', name: 'run_shell_command', args: { command: 'ls' }, isClientInitiated: false, prompt_id: 'prompt-id-1', }, ]; const abortController = new AbortController(); await task.scheduleToolCalls(requests, abortController.signal); expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled(); }); it('should create a checkpoint if a restorable tool is called', async () => { const mockConfig = createMockConfig({ getCheckpointingEnabled: () => true, getGitService: () => Promise.resolve({} as GitService), }); mockProcessRestorableToolCalls.mockResolvedValue({ checkpointsToWrite: new Map([['test.json', 'test content']]), toolCallToCheckpointMap: new Map(), errors: [], }); // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const requests: ToolCallRequestInfo[] = [ { callId: '1', name: 'replace', args: { file_path: 'test.txt', old_string: 'old', new_string: 'new', }, isClientInitiated: false, prompt_id: 'prompt-id-1', }, ]; const abortController = new AbortController(); await task.scheduleToolCalls(requests, abortController.signal); expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce(); }); it('should process all restorable tools for checkpointing in a single batch', async () => { const mockConfig = createMockConfig({ getCheckpointingEnabled: () => true, getGitService: () => Promise.resolve({} as GitService), }); mockProcessRestorableToolCalls.mockResolvedValue({ checkpointsToWrite: new Map([ ['test1.json', 'test content 1'], ['test2.json', 'test content 2'], ]), toolCallToCheckpointMap: new Map([ ['1', 'test1'], ['2', 'test2'], ]), errors: [], }); // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const requests: ToolCallRequestInfo[] = [ { callId: '1', name: 'replace', args: { file_path: 'test.txt', old_string: 'old', new_string: 'new', }, isClientInitiated: false, prompt_id: 'prompt-id-1', }, { callId: '2', name: 'write_file', args: { file_path: 'test2.txt', content: 'new content' }, isClientInitiated: false, prompt_id: 'prompt-id-2', }, { callId: '3', name: 'not_restorable', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-3', }, ]; const abortController = new AbortController(); await task.scheduleToolCalls(requests, abortController.signal); expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith( [ expect.objectContaining({ callId: '1' }), expect.objectContaining({ callId: '2' }), ], expect.anything(), expect.anything(), ); }); }); describe('acceptAgentMessage', () => { it('should set currentTraceId when event has traceId', async () => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const event = { type: 'content', value: 'test', traceId: 'test-trace-id', }; await task.acceptAgentMessage(event); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ traceId: 'test-trace-id', }), }), ); }); it('should handle Citation event and publish to event bus', async () => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const citationText = 'Source: example.com'; const citationEvent = { type: GeminiEventType.Citation, value: citationText, }; await task.acceptAgentMessage(citationEvent); expect(mockEventBus.publish).toHaveBeenCalledOnce(); const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0]; expect(publishedEvent.kind).toBe('status-update'); expect(publishedEvent.taskId).toBe('task-id'); expect(publishedEvent.metadata.coderAgent.kind).toBe( CoderAgentEvent.CitationEvent, ); expect(publishedEvent.status.message).toBeDefined(); expect(publishedEvent.status.message.parts).toEqual([ { kind: 'text', text: citationText, }, ]); }); it('should update modelInfo and reflect it in metadata and status updates', async () => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // @ts-expect-error - Calling private constructor for test purposes. const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const modelInfoEvent = { type: GeminiEventType.ModelInfo, value: 'new-model-name', }; await task.acceptAgentMessage(modelInfoEvent); expect(task.modelInfo).toBe('new-model-name'); // Check getMetadata const metadata = await task.getMetadata(); expect(metadata.model).toBe('new-model-name'); // Check status update task.setTaskStateAndPublishUpdate( 'working', { kind: CoderAgentEvent.StateChangeEvent }, 'Working...', ); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ model: 'new-model-name', }), }), ); }); it.each([ { eventType: GeminiEventType.Retry, eventName: 'Retry' }, { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' }, ])( 'should handle $eventName event without triggering error handling', async ({ eventType }) => { const mockConfig = createMockConfig(); const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // @ts-expect-error - Calling private constructor const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools'); const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate'); const event = { type: eventType, }; await task.acceptAgentMessage(event); expect(cancelPendingToolsSpy).not.toHaveBeenCalled(); expect(setTaskStateSpy).not.toHaveBeenCalled(); }, ); }); describe('currentPromptId and promptCount', () => { it('should correctly initialize and update promptId and promptCount', async () => { const mockConfig = createMockConfig(); mockConfig.getGeminiClient = vi.fn().mockReturnValue({ sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), }); mockConfig.getSessionId = () => 'test-session-id'; const mockEventBus: ExecutionEventBus = { publish: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn(), finished: vi.fn(), }; // @ts-expect-error - Calling private constructor const task = new Task( 'task-id', 'context-id', mockConfig as Config, mockEventBus, ); // Initial state expect(task.currentPromptId).toBeUndefined(); expect(task.promptCount).toBe(0); // First user message should set prompt_id const userMessage1 = { userMessage: { parts: [{ kind: 'text', text: 'hello' }], }, } as RequestContext; const abortController1 = new AbortController(); for await (const _ of task.acceptUserMessage( userMessage1, abortController1.signal, )) { // no-op } const expectedPromptId1 = 'test-session-id########0'; expect(task.promptCount).toBe(1); expect(task.currentPromptId).toBe(expectedPromptId1); // A new user message should generate a new prompt_id const userMessage2 = { userMessage: { parts: [{ kind: 'text', text: 'world' }], }, } as RequestContext; const abortController2 = new AbortController(); for await (const _ of task.acceptUserMessage( userMessage2, abortController2.signal, )) { // no-op } const expectedPromptId2 = 'test-session-id########1'; expect(task.promptCount).toBe(2); expect(task.currentPromptId).toBe(expectedPromptId2); // Subsequent tool call processing should use the same prompt_id const completedTool = { request: { callId: 'tool-1' }, response: { responseParts: [{ text: 'tool output' }] }, } as CompletedToolCall; const abortController3 = new AbortController(); for await (const _ of task.sendCompletedToolsToLlm( [completedTool], abortController3.signal, )) { // no-op } expect(task.promptCount).toBe(2); expect(task.currentPromptId).toBe(expectedPromptId2); }); }); }); ================================================ FILE: packages/a2a-server/src/agent/task.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type AgentLoopContext, Scheduler, type GeminiClient, GeminiEventType, ToolConfirmationOutcome, ApprovalMode, getAllMCPServerStatuses, MCPServerStatus, isNodeError, getErrorMessage, parseAndFormatApiError, safeLiteralReplace, DEFAULT_GUI_EDITOR, type AnyDeclarativeTool, type ToolCall, type ToolConfirmationPayload, type CompletedToolCall, type ToolCallRequestInfo, type ServerGeminiErrorEvent, type ServerGeminiStreamEvent, type ToolCallConfirmationDetails, type Config, type UserTierId, type ToolLiveOutput, type AnsiLine, type AnsiOutput, type AnsiToken, isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, MessageBusType, type ToolCallsUpdateMessage, } from '@google/gemini-cli-core'; import { type ExecutionEventBus, type RequestContext, } from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskState, Message, Part, Artifact, } from '@a2a-js/sdk'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { CoderAgentEvent, type CoderAgentMessage, type StateChange, type ToolCallUpdate, type TextContent, type TaskMetadata, type Thought, type ThoughtSummary, type Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; type UnionKeys = T extends T ? keyof T : never; export class Task { id: string; contextId: string; scheduler: Scheduler; config: Config; geminiClient: GeminiClient; pendingToolConfirmationDetails: Map; pendingCorrelationIds: Map = new Map(); taskState: TaskState; eventBus?: ExecutionEventBus; completedToolCalls: CompletedToolCall[]; processedToolCallIds: Set = new Set(); skipFinalTrueAfterInlineEdit = false; modelInfo?: string; currentPromptId: string | undefined; currentAgentMessageId = uuidv4(); promptCount = 0; autoExecute: boolean; private get isYoloMatch(): boolean { return ( this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO ); } // For tool waiting logic private pendingToolCalls: Map = new Map(); //toolCallId --> status private toolsAlreadyConfirmed: Set = new Set(); private toolCompletionPromise?: Promise; private toolCompletionNotifier?: { resolve: () => void; reject: (reason?: Error) => void; }; private constructor( id: string, contextId: string, config: Config, eventBus?: ExecutionEventBus, autoExecute = false, ) { this.id = id; this.contextId = contextId; this.config = config; this.scheduler = this.setupEventDrivenScheduler(); const loopContext: AgentLoopContext = this.config; this.geminiClient = loopContext.geminiClient; this.pendingToolConfirmationDetails = new Map(); this.taskState = 'submitted'; this.eventBus = eventBus; this.completedToolCalls = []; this._resetToolCompletionPromise(); this.autoExecute = autoExecute; this.config.setFallbackModelHandler( // For a2a-server, we want to automatically switch to the fallback model // for future requests without retrying the current one. The 'stop' // intent achieves this. async () => 'stop', ); } static async create( id: string, contextId: string, config: Config, eventBus?: ExecutionEventBus, autoExecute?: boolean, ): Promise { return new Task(id, contextId, config, eventBus, autoExecute); } // Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire // process. This is not scoped to the individual task but reflects the global connection // state managed within the @gemini-cli/core module. async getMetadata(): Promise { const loopContext: AgentLoopContext = this.config; const toolRegistry = loopContext.toolRegistry; const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; const serverStatuses = getAllMCPServerStatuses(); const servers = Object.keys(mcpServers).map((serverName) => ({ name: serverName, status: serverStatuses.get(serverName) || MCPServerStatus.DISCONNECTED, tools: toolRegistry.getToolsByServer(serverName).map((tool) => ({ name: tool.name, description: tool.description, parameterSchema: tool.schema.parameters, })), })); const availableTools = toolRegistry.getAllTools().map((tool) => ({ name: tool.name, description: tool.description, parameterSchema: tool.schema.parameters, })); const metadata: TaskMetadata = { id: this.id, contextId: this.contextId, taskState: this.taskState, model: this.modelInfo || this.config.getModel(), mcpServers: servers, availableTools, }; return metadata; } private _resetToolCompletionPromise(): void { this.toolCompletionPromise = new Promise((resolve, reject) => { this.toolCompletionNotifier = { resolve, reject }; }); // If there are no pending calls when reset, resolve immediately. if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) { this.toolCompletionNotifier.resolve(); } } private _registerToolCall(toolCallId: string, status: string): void { const wasEmpty = this.pendingToolCalls.size === 0; this.pendingToolCalls.set(toolCallId, status); if (wasEmpty) { this._resetToolCompletionPromise(); } logger.info( `[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`, ); } private _resolveToolCall(toolCallId: string): void { if (this.pendingToolCalls.has(toolCallId)) { this.pendingToolCalls.delete(toolCallId); logger.info( `[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`, ); if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) { this.toolCompletionNotifier.resolve(); } } } async waitForPendingTools(): Promise { if (this.pendingToolCalls.size === 0) { return Promise.resolve(); } logger.info( `[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`, ); await this.toolCompletionPromise; } cancelPendingTools(reason: string): void { if (this.pendingToolCalls.size > 0) { logger.info( `[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`, ); } if (this.toolCompletionNotifier) { this.toolCompletionNotifier.reject(new Error(reason)); } this.pendingToolCalls.clear(); this.pendingCorrelationIds.clear(); this.scheduler.cancelAll(); // Reset the promise for any future operations, ensuring it's in a clean state. this._resetToolCompletionPromise(); } private _createTextMessage( text: string, role: 'agent' | 'user' = 'agent', ): Message { return { kind: 'message', role, parts: [{ kind: 'text', text }], messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(), taskId: this.id, contextId: this.contextId, }; } private _createStatusUpdateEvent( stateToReport: TaskState, coderAgentMessage: CoderAgentMessage, message?: Message, final = false, timestamp?: string, metadataError?: string, traceId?: string, ): TaskStatusUpdateEvent { const metadata: { coderAgent: CoderAgentMessage; model: string; userTier?: UserTierId; error?: string; traceId?: string; } = { coderAgent: coderAgentMessage, model: this.modelInfo || this.config.getModel(), userTier: this.config.getUserTier(), }; if (metadataError) { metadata.error = metadataError; } if (traceId) { metadata.traceId = traceId; } return { kind: 'status-update', taskId: this.id, contextId: this.contextId, status: { state: stateToReport, message, // Shorthand property timestamp: timestamp || new Date().toISOString(), }, final, metadata, }; } setTaskStateAndPublishUpdate( newState: TaskState, coderAgentMessage: CoderAgentMessage, messageText?: string, messageParts?: Part[], // For more complex messages final = false, metadataError?: string, traceId?: string, ): void { this.taskState = newState; let message: Message | undefined; if (messageText) { message = this._createTextMessage(messageText); } else if (messageParts) { message = { kind: 'message', role: 'agent', parts: messageParts, messageId: uuidv4(), taskId: this.id, contextId: this.contextId, }; } const event = this._createStatusUpdateEvent( this.taskState, coderAgentMessage, message, final, undefined, metadataError, traceId, ); this.eventBus?.publish(event); } private _schedulerOutputUpdate( toolCallId: string, outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; } else if (isSubagentProgress(outputChunk)) { outputAsText = JSON.stringify(outputChunk); } else if (Array.isArray(outputChunk)) { const ansiOutput: AnsiOutput = outputChunk; outputAsText = ansiOutput .map((line: AnsiLine) => line.map((token: AnsiToken) => token.text).join(''), ) .join('\n'); } else { outputAsText = String(outputChunk); } logger.info( '[Task] Scheduler output update for tool call ' + toolCallId + ': ' + outputAsText, ); const artifact: Artifact = { artifactId: `tool-${toolCallId}-output`, parts: [ { kind: 'text', text: outputAsText, } as Part, ], }; const artifactEvent: TaskArtifactUpdateEvent = { kind: 'artifact-update', taskId: this.id, contextId: this.contextId, artifact, append: true, lastChunk: false, }; this.eventBus?.publish(artifactEvent); } private messageBusListener?: (message: ToolCallsUpdateMessage) => void; private setupEventDrivenScheduler(): Scheduler { const loopContext: AgentLoopContext = this.config; const messageBus = loopContext.messageBus; const scheduler = new Scheduler({ schedulerId: this.id, context: this.config, messageBus, getPreferredEditor: () => DEFAULT_GUI_EDITOR, }); this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this); messageBus.subscribe( MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener, ); return scheduler; } dispose(): void { if (this.messageBusListener) { const loopContext: AgentLoopContext = this.config; loopContext.messageBus.unsubscribe( MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener, ); this.messageBusListener = undefined; } this.scheduler.dispose(); } private handleEventDrivenToolCallsUpdate( event: ToolCallsUpdateMessage, ): void { if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) { return; } const toolCalls = event.toolCalls; toolCalls.forEach((tc) => { this.handleEventDrivenToolCall(tc); }); this.checkInputRequiredState(); } private handleEventDrivenToolCall(tc: ToolCall): void { const callId = tc.request.callId; // Do not process events for tools that have already been finalized. // This prevents duplicate completions if the state manager emits a snapshot containing // already resolved tools whose IDs were removed from pendingToolCalls. if ( this.processedToolCallIds.has(callId) || this.completedToolCalls.some((c) => c.request.callId === callId) ) { return; } const previousStatus = this.pendingToolCalls.get(callId); const hasChanged = previousStatus !== tc.status; // 1. Handle Output if (tc.status === 'executing' && tc.liveOutput) { this._schedulerOutputUpdate(callId, tc.liveOutput); } // 2. Handle terminal states if ( tc.status === 'success' || tc.status === 'error' || tc.status === 'cancelled' ) { this.toolsAlreadyConfirmed.delete(callId); if (hasChanged) { logger.info( `[Task] Tool call ${callId} completed with status: ${tc.status}`, ); this.completedToolCalls.push(tc); this._resolveToolCall(callId); } } else { // Keep track of pending tools this._registerToolCall(callId, tc.status); } // 3. Handle Confirmation Stash if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { const details = tc.confirmationDetails; if (tc.correlationId) { this.pendingCorrelationIds.set(callId, tc.correlationId); } this.pendingToolConfirmationDetails.set(callId, { ...details, onConfirm: async () => {}, } as ToolCallConfirmationDetails); } // 4. Publish Status Updates to A2A event bus if (hasChanged) { const coderAgentMessage: CoderAgentMessage = tc.status === 'awaiting_approval' ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } : { kind: CoderAgentEvent.ToolCallUpdateEvent }; const message = this.toolStatusMessage(tc, this.id, this.contextId); const statusUpdate = this._createStatusUpdateEvent( this.taskState, coderAgentMessage, message, false, ); this.eventBus?.publish(statusUpdate); } } private checkInputRequiredState(): void { if (this.isYoloMatch) { return; } // 6. Handle Input Required State let isAwaitingApproval = false; let isExecuting = false; for (const [callId, status] of this.pendingToolCalls.entries()) { if (status === 'executing' || status === 'scheduled') { isExecuting = true; } else if ( status === 'awaiting_approval' && !this.toolsAlreadyConfirmed.has(callId) ) { isAwaitingApproval = true; } } if ( isAwaitingApproval && !isExecuting && !this.skipFinalTrueAfterInlineEdit ) { this.skipFinalTrueAfterInlineEdit = false; const wasAlreadyInputRequired = this.taskState === 'input-required'; this.setTaskStateAndPublishUpdate( 'input-required', { kind: CoderAgentEvent.StateChangeEvent }, undefined, undefined, /*final*/ true, ); // Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream. // The IDE client will open a new stream with the confirmation reply. if (!wasAlreadyInputRequired && this.toolCompletionNotifier) { this.toolCompletionNotifier.resolve(); } } } private _pickFields< T extends ToolCall | AnyDeclarativeTool, K extends UnionKeys, >(from: T, ...fields: K[]): Partial { const ret: Partial = {}; for (const field of fields) { if (field in from && from[field] !== undefined) { ret[field] = from[field]; } } return ret; } private toolStatusMessage( tc: ToolCall, taskId: string, contextId: string, ): Message { const messageParts: Part[] = []; // Create a serializable version of the ToolCall (pick necessary // properties/avoid methods causing circular reference errors). // Type allows tool to be Partial for serialization. const serializableToolCall: Partial> & { tool?: Partial; } = this._pickFields( tc, 'request', 'status', 'confirmationDetails', 'liveOutput', 'response', ); if (tc.tool) { const toolFields = this._pickFields( tc.tool, 'name', 'displayName', 'description', 'kind', 'isOutputMarkdown', 'canUpdateOutput', 'schema', 'parameterSchema', ); serializableToolCall.tool = toolFields; } messageParts.push({ kind: 'data', data: serializableToolCall, } as Part); return { kind: 'message', role: 'agent', parts: messageParts, messageId: uuidv4(), taskId, contextId, }; } private async getProposedContent( file_path: string, old_string: string, new_string: string, ): Promise { // Validate path to prevent path traversal vulnerabilities const resolvedPath = path.resolve(this.config.getTargetDir(), file_path); const pathError = this.config.validatePathAccess(resolvedPath, 'read'); if (pathError) { throw new Error(`Path validation failed: ${pathError}`); } try { const currentContent = await fs.readFile(resolvedPath, 'utf8'); return this._applyReplacement( currentContent, old_string, new_string, old_string === '' && currentContent === '', ); } catch (err) { if (!isNodeError(err) || err.code !== 'ENOENT') throw err; return ''; } } private _applyReplacement( currentContent: string | null, oldString: string, newString: string, isNewFile: boolean, ): string { if (isNewFile) { return newString; } if (currentContent === null) { // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty return oldString === '' ? newString : ''; } // If oldString is empty and it's not a new file, do not modify the content. if (oldString === '' && !isNewFile) { return currentContent; } // Use intelligent replacement that handles $ sequences safely return safeLiteralReplace(currentContent, oldString, newString); } async scheduleToolCalls( requests: ToolCallRequestInfo[], abortSignal: AbortSignal, ): Promise { if (requests.length === 0) { return; } // Set checkpoint file before any file modification tool executes const restorableToolCalls = requests.filter((request) => EDIT_TOOL_NAMES.has(request.name), ); if ( restorableToolCalls.length > 0 && this.config.getCheckpointingEnabled() ) { const gitService = await this.config.getGitService(); if (gitService) { const { checkpointsToWrite, toolCallToCheckpointMap, errors } = await processRestorableToolCalls( restorableToolCalls, gitService, this.geminiClient, ); if (errors.length > 0) { errors.forEach((error) => logger.error(error)); } if (checkpointsToWrite.size > 0) { const checkpointDir = this.config.storage.getProjectTempCheckpointsDir(); await fs.mkdir(checkpointDir, { recursive: true }); for (const [fileName, content] of checkpointsToWrite) { const filePath = path.join(checkpointDir, fileName); await fs.writeFile(filePath, content); } } for (const request of requests) { const checkpoint = toolCallToCheckpointMap.get(request.callId); if (checkpoint) { request.checkpoint = checkpoint; } } } } const updatedRequests = await Promise.all( requests.map(async (request) => { if ( request.name === 'replace' && request.args && !request.args['newContent'] && request.args['file_path'] && request.args['old_string'] && request.args['new_string'] ) { const filePath = request.args['file_path']; const oldString = request.args['old_string']; const newString = request.args['new_string']; if ( typeof filePath === 'string' && typeof oldString === 'string' && typeof newString === 'string' ) { // Resolve and validate path to prevent path traversal (user-controlled file_path). const resolvedPath = path.resolve( this.config.getTargetDir(), filePath, ); const pathError = this.config.validatePathAccess( resolvedPath, 'read', ); if (!pathError) { const newContent = await this.getProposedContent( resolvedPath, oldString, newString, ); return { ...request, args: { ...request.args, newContent } }; } } } return request; }), ); logger.info( `[Task] Scheduling batch of ${updatedRequests.length} tool calls.`, ); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; this.setTaskStateAndPublishUpdate('working', stateChange); // Pre-register tools to ensure waitForPendingTools sees them as pending // before the async scheduler enqueues them and fires the event bus update. for (const req of updatedRequests) { if (!this.pendingToolCalls.has(req.callId)) { this._registerToolCall(req.callId, 'scheduled'); } } // Fire and forget so we don't block the executor loop before waitForPendingTools can be called void this.scheduler.schedule(updatedRequests, abortSignal); } async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise { const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; const traceId = 'traceId' in event && event.traceId ? event.traceId : undefined; switch (event.type) { case GeminiEventType.Content: logger.info('[Task] Sending agent message content...'); this._sendTextContent(event.value, traceId); break; case GeminiEventType.ToolCallRequest: // This is now handled by the agent loop, which collects all requests // and calls scheduleToolCalls once. logger.warn( '[Task] A single tool call request was passed to acceptAgentMessage. This should be handled in a batch by the agent. Ignoring.', ); break; case GeminiEventType.ToolCallResponse: // This event type from ServerGeminiStreamEvent might be for when LLM *generates* a tool response part. // The actual execution result comes via user message. logger.info( '[Task] Received tool call response from LLM (part of generation):', event.value, ); break; case GeminiEventType.ToolCallConfirmation: // This is when LLM requests confirmation, not when user provides it. logger.info( '[Task] Received tool call confirmation request from LLM:', event.value.request.callId, ); this.pendingToolConfirmationDetails.set( event.value.request.callId, event.value.details, ); // This will be handled by the scheduler and _schedulerToolCallsUpdate will set InputRequired if needed. // No direct state change here, scheduler drives it. break; case GeminiEventType.UserCancelled: logger.info('[Task] Received user cancelled event from LLM stream.'); this.cancelPendingTools('User cancelled via LLM stream event'); this.setTaskStateAndPublishUpdate( 'input-required', stateChange, 'Task cancelled by user', undefined, true, undefined, traceId, ); break; case GeminiEventType.Thought: logger.info('[Task] Sending agent thought...'); this._sendThought(event.value, traceId); break; case GeminiEventType.Citation: logger.info('[Task] Received citation from LLM stream.'); this._sendCitation(event.value); break; case GeminiEventType.ChatCompressed: break; case GeminiEventType.Finished: logger.info(`[Task ${this.id}] Agent finished its turn.`); break; case GeminiEventType.ModelInfo: this.modelInfo = event.value; break; case GeminiEventType.Retry: case GeminiEventType.InvalidStream: // An invalid stream should trigger a retry, which requires no action from the user. break; case GeminiEventType.Error: default: { // Use type guard instead of unsafe type assertion let errorEvent: ServerGeminiErrorEvent | undefined; if ( event.type === GeminiEventType.Error && event.value && typeof event.value === 'object' && 'error' in event.value ) { errorEvent = event; } const errorMessage = errorEvent?.value?.error ? getErrorMessage(errorEvent.value.error) : 'Unknown error from LLM stream'; logger.error( '[Task] Received error event from LLM stream:', errorMessage, ); let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`; if (errorEvent?.value?.error) { errMessage = parseAndFormatApiError(errorEvent.value.error); } this.cancelPendingTools(`LLM stream error: ${errorMessage}`); this.setTaskStateAndPublishUpdate( this.taskState, stateChange, `Agent Error, unknown agent message: ${errorMessage}`, undefined, false, errMessage, traceId, ); break; } } } private async _handleToolConfirmationPart(part: Part): Promise { if ( part.kind !== 'data' || !part.data || // eslint-disable-next-line no-restricted-syntax typeof part.data['callId'] !== 'string' || // eslint-disable-next-line no-restricted-syntax typeof part.data['outcome'] !== 'string' ) { return false; } if (!part.data['outcome']) { return false; } const callId = part.data['callId']; const outcomeString = part.data['outcome']; this.toolsAlreadyConfirmed.add(callId); let confirmationOutcome: ToolConfirmationOutcome | undefined; if (outcomeString === 'proceed_once') { confirmationOutcome = ToolConfirmationOutcome.ProceedOnce; } else if (outcomeString === 'cancel') { confirmationOutcome = ToolConfirmationOutcome.Cancel; } else if (outcomeString === 'proceed_always') { confirmationOutcome = ToolConfirmationOutcome.ProceedAlways; } else if (outcomeString === 'proceed_always_server') { confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer; } else if (outcomeString === 'proceed_always_tool') { confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool; } else if (outcomeString === 'proceed_always_and_save') { confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave; } else if (outcomeString === 'modify_with_editor') { confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor; } else { logger.warn( `[Task] Unknown tool confirmation outcome: "${outcomeString}" for callId: ${callId}`, ); return false; } const confirmationDetails = this.pendingToolConfirmationDetails.get(callId); const correlationId = this.pendingCorrelationIds.get(callId); if (!confirmationDetails && !correlationId) { logger.warn( `[Task] Received tool confirmation for unknown or already processed callId: ${callId}`, ); return false; } logger.info( `[Task] Handling tool confirmation for callId: ${callId} with outcome: ${outcomeString}`, ); try { // Temporarily unset GCP environment variables so they do not leak into // tool calls. const gcpProject = process.env['GOOGLE_CLOUD_PROJECT']; const gcpCreds = process.env['GOOGLE_APPLICATION_CREDENTIALS']; try { delete process.env['GOOGLE_CLOUD_PROJECT']; delete process.env['GOOGLE_APPLICATION_CREDENTIALS']; // This will trigger the scheduler to continue or cancel the specific tool. // The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled). // If `edit` tool call, pass updated payload if present const newContent = part.data['newContent']; const payload = confirmationDetails?.type === 'edit' && typeof newContent === 'string' ? ({ newContent } as ToolConfirmationPayload) : undefined; this.skipFinalTrueAfterInlineEdit = !!payload; try { if (correlationId) { const loopContext: AgentLoopContext = this.config; await loopContext.messageBus.publish({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId, confirmed: confirmationOutcome !== ToolConfirmationOutcome.Cancel && confirmationOutcome !== ToolConfirmationOutcome.ModifyWithEditor, outcome: confirmationOutcome, payload, }); } else if (confirmationDetails?.onConfirm) { // Fallback for legacy callback-based confirmation await confirmationDetails.onConfirm(confirmationOutcome, payload); } } finally { // Once confirmation payload is sent or callback finishes, // reset skipFinalTrueAfterInlineEdit so that external callers receive // their call has been completed. this.skipFinalTrueAfterInlineEdit = false; } } finally { if (gcpProject) { process.env['GOOGLE_CLOUD_PROJECT'] = gcpProject; } if (gcpCreds) { process.env['GOOGLE_APPLICATION_CREDENTIALS'] = gcpCreds; } } // Do not delete if modifying, a subsequent tool confirmation for the same // callId will be passed with ProceedOnce/Cancel/etc // Note !== ToolConfirmationOutcome.ModifyWithEditor does not work! if (confirmationOutcome !== 'modify_with_editor') { this.pendingToolConfirmationDetails.delete(callId); this.pendingCorrelationIds.delete(callId); } // If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool. // If ProceedOnce, scheduler updates to 'executing', then eventually 'success'/'error', which resolves. return true; } catch (error) { logger.error( `[Task] Error during tool confirmation for callId ${callId}:`, error, ); // If confirming fails, we should probably mark this tool as failed this._resolveToolCall(callId); // Resolve it as it won't proceed. const errorMessageText = error instanceof Error ? error.message : `Error processing tool confirmation for ${callId}`; const message = this._createTextMessage(errorMessageText); const toolCallUpdate: ToolCallUpdate = { kind: CoderAgentEvent.ToolCallUpdateEvent, }; const event = this._createStatusUpdateEvent( this.taskState, toolCallUpdate, message, false, ); this.eventBus?.publish(event); return false; } } getAndClearCompletedTools(): CompletedToolCall[] { const tools = [...this.completedToolCalls]; for (const tool of tools) { this.processedToolCallIds.add(tool.request.callId); } this.completedToolCalls = []; return tools; } addToolResponsesToHistory(completedTools: CompletedToolCall[]): void { logger.info( `[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`, ); const responsesToAdd = completedTools.flatMap( (toolCall) => toolCall.response.responseParts, ); for (const response of responsesToAdd) { let parts: genAiPart[]; if (Array.isArray(response)) { parts = response; } else if (typeof response === 'string') { parts = [{ text: response }]; } else { parts = [response]; } // eslint-disable-next-line @typescript-eslint/no-floating-promises this.geminiClient.addHistory({ role: 'user', parts, }); } } async *sendCompletedToolsToLlm( completedToolCalls: CompletedToolCall[], aborted: AbortSignal, ): AsyncGenerator { if (completedToolCalls.length === 0) { yield* (async function* () {})(); // Yield nothing return; } const llmParts: PartUnion[] = []; logger.info( `[Task] Feeding ${completedToolCalls.length} tool responses to LLM.`, ); for (const completedToolCall of completedToolCalls) { logger.info( `[Task] Adding tool response for "${completedToolCall.request.name}" (callId: ${completedToolCall.request.callId}) to LLM input.`, ); const responseParts = completedToolCall.response.responseParts; if (Array.isArray(responseParts)) { llmParts.push(...responseParts); } else { llmParts.push(responseParts); } } logger.info('[Task] Sending new parts to agent.'); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; // Set task state to working as we are about to call LLM this.setTaskStateAndPublishUpdate('working', stateChange); this.currentAgentMessageId = uuidv4(); yield* this.geminiClient.sendMessageStream( llmParts, aborted, completedToolCalls[0]?.request.prompt_id ?? '', ); } async *acceptUserMessage( requestContext: RequestContext, aborted: AbortSignal, ): AsyncGenerator { const userMessage = requestContext.userMessage; const llmParts: PartUnion[] = []; let anyConfirmationHandled = false; let hasContentForLlm = false; for (const part of userMessage.parts) { const confirmationHandled = await this._handleToolConfirmationPart(part); if (confirmationHandled) { anyConfirmationHandled = true; // If a confirmation was handled, the scheduler will now run the tool (or cancel it). // We resolve the toolCompletionPromise manually in checkInputRequiredState // to break the original execution loop, so we must reset it here so the // new loop correctly awaits the tool's final execution. this._resetToolCompletionPromise(); // We don't send anything to the LLM for this part. // The subsequent tool execution will eventually lead to resolveToolCall. continue; } if (part.kind === 'text') { llmParts.push({ text: part.text }); hasContentForLlm = true; } } if (hasContentForLlm) { this.currentPromptId = this.config.getSessionId() + '########' + this.promptCount++; this.currentAgentMessageId = uuidv4(); logger.info('[Task] Sending new parts to LLM.'); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; // Set task state to working as we are about to call LLM this.setTaskStateAndPublishUpdate('working', stateChange); yield* this.geminiClient.sendMessageStream( llmParts, aborted, this.currentPromptId, ); } else if (anyConfirmationHandled) { logger.info( '[Task] User message only contained tool confirmations. Scheduler is active. No new input for LLM this turn.', ); // Ensure task state reflects that scheduler might be working due to confirmation. // If scheduler is active, it will emit its own status updates. // If all pending tools were just confirmed, waitForPendingTools will handle the wait. // If some tools are still pending approval, scheduler would have set InputRequired. // If not, and no new text, we are just waiting. if ( this.pendingToolCalls.size > 0 && this.taskState !== 'input-required' ) { const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; this.setTaskStateAndPublishUpdate('working', stateChange); // Reflect potential background activity } yield* (async function* () {})(); // Yield nothing } else { logger.info( '[Task] No relevant parts in user message for LLM interaction or tool confirmation.', ); // If there's no new text and no confirmations, and no pending tools, // it implies we might need to signal input required if nothing else is happening. // However, the agent.ts will make this determination after waitForPendingTools. yield* (async function* () {})(); // Yield nothing } } _sendTextContent(content: string, traceId?: string): void { if (content === '') { return; } const message = this._createTextMessage(content); const textContent: TextContent = { kind: CoderAgentEvent.TextContentEvent, }; this.eventBus?.publish( this._createStatusUpdateEvent( this.taskState, textContent, message, false, undefined, undefined, traceId, ), ); } _sendThought(content: ThoughtSummary, traceId?: string): void { if (!content.subject && !content.description) { return; } logger.info('[Task] Sending thought to event bus.'); const message: Message = { kind: 'message', role: 'agent', parts: [ { kind: 'data', data: content, } as Part, ], messageId: this.currentAgentMessageId, taskId: this.id, contextId: this.contextId, }; const thought: Thought = { kind: CoderAgentEvent.ThoughtEvent, }; this.eventBus?.publish( this._createStatusUpdateEvent( this.taskState, thought, message, false, undefined, undefined, traceId, ), ); } _sendCitation(citation: string) { if (!citation || citation.trim() === '') { return; } logger.info('[Task] Sending citation to event bus.'); const message = this._createTextMessage(citation); const citationEvent: Citation = { kind: CoderAgentEvent.CitationEvent, }; this.eventBus?.publish( this._createStatusUpdateEvent(this.taskState, citationEvent, message), ); } } ================================================ FILE: packages/a2a-server/src/commands/command-registry.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Command } from './types.js'; const { mockExtensionsCommand, mockListExtensionsCommand, mockExtensionsCommandInstance, mockListExtensionsCommandInstance, } = vi.hoisted(() => { const listInstance: Command = { name: 'extensions list', description: 'Lists all installed extensions.', execute: vi.fn(), }; const extInstance: Command = { name: 'extensions', description: 'Manage extensions.', execute: vi.fn(), subCommands: [listInstance], }; return { mockListExtensionsCommandInstance: listInstance, mockExtensionsCommandInstance: extInstance, mockExtensionsCommand: vi.fn(() => extInstance), mockListExtensionsCommand: vi.fn(() => listInstance), }; }); vi.mock('./extensions.js', () => ({ ExtensionsCommand: mockExtensionsCommand, ListExtensionsCommand: mockListExtensionsCommand, })); vi.mock('./init.js', () => ({ InitCommand: vi.fn(() => ({ name: 'init', description: 'Initializes the server.', execute: vi.fn(), })), })); vi.mock('./restore.js', () => ({ RestoreCommand: vi.fn(() => ({ name: 'restore', description: 'Restores the server.', execute: vi.fn(), })), })); import { commandRegistry } from './command-registry.js'; describe('CommandRegistry', () => { beforeEach(async () => { vi.clearAllMocks(); commandRegistry.initialize(); }); it('should register ExtensionsCommand on initialization', async () => { expect(mockExtensionsCommand).toHaveBeenCalled(); const command = commandRegistry.get('extensions'); expect(command).toBe(mockExtensionsCommandInstance); }, 20000); it('should register sub commands on initialization', async () => { const command = commandRegistry.get('extensions list'); expect(command).toBe(mockListExtensionsCommandInstance); }); it('get() should return undefined for a non-existent command', async () => { const command = commandRegistry.get('non-existent'); expect(command).toBeUndefined(); }); it('register() should register a new command', async () => { const mockCommand: Command = { name: 'test-command', description: '', execute: vi.fn(), }; commandRegistry.register(mockCommand); const command = commandRegistry.get('test-command'); expect(command).toBe(mockCommand); }); it('register() should register a nested command', async () => { const mockSubSubCommand: Command = { name: 'test-command-sub-sub', description: '', execute: vi.fn(), }; const mockSubCommand: Command = { name: 'test-command-sub', description: '', execute: vi.fn(), subCommands: [mockSubSubCommand], }; const mockCommand: Command = { name: 'test-command', description: '', execute: vi.fn(), subCommands: [mockSubCommand], }; commandRegistry.register(mockCommand); const command = commandRegistry.get('test-command'); const subCommand = commandRegistry.get('test-command-sub'); const subSubCommand = commandRegistry.get('test-command-sub-sub'); expect(command).toBe(mockCommand); expect(subCommand).toBe(mockSubCommand); expect(subSubCommand).toBe(mockSubSubCommand); }); it('register() should not enter an infinite loop with a cyclic command', async () => { const { debugLogger } = await import('@google/gemini-cli-core'); const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); const mockCommand: Command = { name: 'cyclic-command', description: '', subCommands: [], execute: vi.fn(), }; mockCommand.subCommands?.push(mockCommand); // Create cycle commandRegistry.register(mockCommand); expect(commandRegistry.get('cyclic-command')).toBe(mockCommand); expect(warnSpy).toHaveBeenCalledWith( 'Command cyclic-command already registered. Skipping.', ); warnSpy.mockRestore(); }); }); ================================================ FILE: packages/a2a-server/src/commands/command-registry.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { MemoryCommand } from './memory.js'; import { debugLogger } from '@google/gemini-cli-core'; import { ExtensionsCommand } from './extensions.js'; import { InitCommand } from './init.js'; import { RestoreCommand } from './restore.js'; import type { Command } from './types.js'; export class CommandRegistry { private readonly commands = new Map(); constructor() { this.initialize(); } initialize() { this.commands.clear(); this.register(new ExtensionsCommand()); this.register(new RestoreCommand()); this.register(new InitCommand()); this.register(new MemoryCommand()); } register(command: Command) { if (this.commands.has(command.name)) { debugLogger.warn(`Command ${command.name} already registered. Skipping.`); return; } this.commands.set(command.name, command); for (const subCommand of command.subCommands ?? []) { this.register(subCommand); } } get(commandName: string): Command | undefined { return this.commands.get(commandName); } getAllCommands(): Command[] { return [...this.commands.values()]; } } export const commandRegistry = new CommandRegistry(); ================================================ FILE: packages/a2a-server/src/commands/extensions.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js'; import type { CommandContext } from './types.js'; const mockListExtensions = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, listExtensions: mockListExtensions, }; }); describe('ExtensionsCommand', () => { it('should have the correct name', () => { const command = new ExtensionsCommand(); expect(command.name).toEqual('extensions'); }); it('should have the correct description', () => { const command = new ExtensionsCommand(); expect(command.description).toEqual('Manage extensions.'); }); it('should have "extensions list" as a subcommand', () => { const command = new ExtensionsCommand(); expect(command.subCommands.map((c) => c.name)).toContain('extensions list'); }); it('should be a top-level command', () => { const command = new ExtensionsCommand(); expect(command.topLevel).toBe(true); }); it('should default to listing extensions', async () => { const command = new ExtensionsCommand(); const mockConfig = { config: {} } as CommandContext; const mockExtensions = [{ name: 'ext1' }]; mockListExtensions.mockReturnValue(mockExtensions); const result = await command.execute(mockConfig, []); expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); }); }); describe('ListExtensionsCommand', () => { it('should have the correct name', () => { const command = new ListExtensionsCommand(); expect(command.name).toEqual('extensions list'); }); it('should call listExtensions with the provided config', async () => { const command = new ListExtensionsCommand(); const mockConfig = { config: {} } as CommandContext; const mockExtensions = [{ name: 'ext1' }]; mockListExtensions.mockReturnValue(mockExtensions); const result = await command.execute(mockConfig, []); expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); }); it('should return a message when no extensions are installed', async () => { const command = new ListExtensionsCommand(); const mockConfig = { config: {} } as CommandContext; mockListExtensions.mockReturnValue([]); const result = await command.execute(mockConfig, []); expect(result).toEqual({ name: 'extensions list', data: 'No extensions installed.', }); expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); }); }); ================================================ FILE: packages/a2a-server/src/commands/extensions.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { listExtensions } from '@google/gemini-cli-core'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; export class ExtensionsCommand implements Command { readonly name = 'extensions'; readonly description = 'Manage extensions.'; readonly subCommands = [new ListExtensionsCommand()]; readonly topLevel = true; async execute( context: CommandContext, _: string[], ): Promise { return new ListExtensionsCommand().execute(context, _); } } export class ListExtensionsCommand implements Command { readonly name = 'extensions list'; readonly description = 'Lists all installed extensions.'; async execute( context: CommandContext, _: string[], ): Promise { const extensions = listExtensions(context.config); const data = extensions.length ? extensions : 'No extensions installed.'; return { name: this.name, data }; } } ================================================ FILE: packages/a2a-server/src/commands/init.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InitCommand } from './init.js'; import { performInit, type CommandActionReturn, type Config, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { CoderAgentExecutor } from '../agent/executor.js'; import { CoderAgentEvent } from '../types.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { createMockConfig } from '../utils/testing_utils.js'; import type { CommandContext } from './types.js'; import { logger } from '../utils/logger.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, performInit: vi.fn(), }; }); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(), writeFileSync: vi.fn(), }; }); vi.mock('../agent/executor.js', () => ({ CoderAgentExecutor: vi.fn().mockImplementation(() => ({ execute: vi.fn(), })), })); vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), error: vi.fn(), }, })); describe('InitCommand', () => { let eventBus: ExecutionEventBus; let command: InitCommand; let context: CommandContext; let publishSpy: ReturnType; let mockExecute: ReturnType; const mockWorkspacePath = path.resolve('/tmp'); beforeEach(() => { process.env['CODER_AGENT_WORKSPACE_PATH'] = mockWorkspacePath; eventBus = { publish: vi.fn(), } as unknown as ExecutionEventBus; command = new InitCommand(); const mockConfig = createMockConfig({ getModel: () => 'gemini-pro', }); const mockExecutorInstance = new CoderAgentExecutor(); context = { config: mockConfig as unknown as Config, agentExecutor: mockExecutorInstance, eventBus, } as CommandContext; publishSpy = vi.spyOn(eventBus, 'publish'); mockExecute = vi.fn(); vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute); vi.clearAllMocks(); }); it('has requiresWorkspace set to true', () => { expect(command.requiresWorkspace).toBe(true); }); describe('execute', () => { it('handles info from performInit', async () => { vi.mocked(performInit).mockReturnValue({ type: 'message', messageType: 'info', content: 'GEMINI.md already exists.', } as CommandActionReturn); await command.execute(context, []); expect(logger.info).toHaveBeenCalledWith( '[EventBus event]: ', expect.objectContaining({ kind: 'status-update', status: expect.objectContaining({ state: 'completed', message: expect.objectContaining({ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], }), }), }), ); expect(publishSpy).toHaveBeenCalledWith( expect.objectContaining({ kind: 'status-update', status: expect.objectContaining({ state: 'completed', message: expect.objectContaining({ parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], }), }), }), ); }); it('handles error from performInit', async () => { vi.mocked(performInit).mockReturnValue({ type: 'message', messageType: 'error', content: 'An error occurred.', } as CommandActionReturn); await command.execute(context, []); expect(publishSpy).toHaveBeenCalledWith( expect.objectContaining({ kind: 'status-update', status: expect.objectContaining({ state: 'failed', message: expect.objectContaining({ parts: [{ kind: 'text', text: 'An error occurred.' }], }), }), }), ); }); describe('when handling submit_prompt', () => { beforeEach(() => { vi.mocked(performInit).mockReturnValue({ type: 'submit_prompt', content: 'Create a new GEMINI.md file.', } as CommandActionReturn); }); it('writes the file and executes the agent', async () => { await command.execute(context, []); expect(fs.writeFileSync).toHaveBeenCalledWith( path.join(mockWorkspacePath, 'GEMINI.md'), '', 'utf8', ); expect(mockExecute).toHaveBeenCalled(); }); it('passes autoExecute to the agent executor', async () => { await command.execute(context, []); expect(mockExecute).toHaveBeenCalledWith( expect.objectContaining({ userMessage: expect.objectContaining({ parts: expect.arrayContaining([ expect.objectContaining({ text: 'Create a new GEMINI.md file.', }), ]), metadata: { coderAgent: { kind: CoderAgentEvent.StateAgentSettingsEvent, workspacePath: mockWorkspacePath, autoExecute: true, }, }, }), }), eventBus, ); }); }); }); }); ================================================ FILE: packages/a2a-server/src/commands/init.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { CoderAgentEvent, type AgentSettings } from '../types.js'; import { performInit } from '@google/gemini-cli-core'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; import type { CoderAgentExecutor } from '../agent/executor.js'; import type { ExecutionEventBus, RequestContext, AgentExecutionEvent, } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; export class InitCommand implements Command { name = 'init'; description = 'Analyzes the project and creates a tailored GEMINI.md file'; requiresWorkspace = true; streaming = true; private handleMessageResult( result: { content: string; messageType: 'info' | 'error' }, context: CommandContext, eventBus: ExecutionEventBus, taskId: string, contextId: string, ): CommandExecutionResponse { const statusState = result.messageType === 'error' ? 'failed' : 'completed'; const eventType = result.messageType === 'error' ? CoderAgentEvent.StateChangeEvent : CoderAgentEvent.TextContentEvent; const event: AgentExecutionEvent = { kind: 'status-update', taskId, contextId, status: { state: statusState, message: { kind: 'message', role: 'agent', parts: [{ kind: 'text', text: result.content }], messageId: uuidv4(), taskId, contextId, }, timestamp: new Date().toISOString(), }, final: true, metadata: { coderAgent: { kind: eventType }, model: context.config.getModel(), }, }; logger.info('[EventBus event]: ', event); eventBus.publish(event); return { name: this.name, data: result, }; } private async handleSubmitPromptResult( result: { content: unknown }, context: CommandContext, geminiMdPath: string, eventBus: ExecutionEventBus, taskId: string, contextId: string, ): Promise { fs.writeFileSync(geminiMdPath, '', 'utf8'); if (!context.agentExecutor) { throw new Error('Agent executor not found in context.'); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentExecutor = context.agentExecutor as CoderAgentExecutor; const agentSettings: AgentSettings = { kind: CoderAgentEvent.StateAgentSettingsEvent, workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!, autoExecute: true, }; if (typeof result.content !== 'string') { throw new Error('Init command content must be a string.'); } const promptText = result.content; const requestContext: RequestContext = { userMessage: { kind: 'message', role: 'user', parts: [{ kind: 'text', text: promptText }], messageId: uuidv4(), taskId, contextId, metadata: { coderAgent: agentSettings, }, }, taskId, contextId, }; // The executor will handle the entire agentic loop, including // creating the task, streaming responses, and handling tools. await agentExecutor.execute(requestContext, eventBus); return { name: this.name, data: geminiMdPath, }; } async execute( context: CommandContext, _args: string[] = [], ): Promise { if (!context.eventBus) { return { name: this.name, data: 'Use executeStream to get streaming results.', }; } const geminiMdPath = path.join( process.env['CODER_AGENT_WORKSPACE_PATH']!, 'GEMINI.md', ); const result = performInit(fs.existsSync(geminiMdPath)); const taskId = uuidv4(); const contextId = uuidv4(); switch (result.type) { case 'message': return this.handleMessageResult( result, context, context.eventBus, taskId, contextId, ); case 'submit_prompt': return this.handleSubmitPromptResult( result, context, geminiMdPath, context.eventBus, taskId, contextId, ); default: throw new Error('Unknown result type from performInit'); } } } ================================================ FILE: packages/a2a-server/src/commands/memory.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { addMemory, listMemoryFiles, refreshMemory, showMemory, type AnyDeclarativeTool, type Config, type ToolRegistry, } from '@google/gemini-cli-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AddMemoryCommand, ListMemoryCommand, MemoryCommand, RefreshMemoryCommand, ShowMemoryCommand, } from './memory.js'; import type { CommandContext } from './types.js'; // Mock the core functions vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, showMemory: vi.fn(), refreshMemory: vi.fn(), listMemoryFiles: vi.fn(), addMemory: vi.fn(), }; }); const mockShowMemory = vi.mocked(showMemory); const mockRefreshMemory = vi.mocked(refreshMemory); const mockListMemoryFiles = vi.mocked(listMemoryFiles); const mockAddMemory = vi.mocked(addMemory); describe('a2a-server memory commands', () => { let mockContext: CommandContext; let mockConfig: Config; let mockToolRegistry: ToolRegistry; let mockSaveMemoryTool: AnyDeclarativeTool; beforeEach(() => { mockSaveMemoryTool = { name: 'save_memory', description: 'Saves memory', buildAndExecute: vi.fn().mockResolvedValue(undefined), } as unknown as AnyDeclarativeTool; mockToolRegistry = { getTool: vi.fn(), } as unknown as ToolRegistry; mockConfig = { get toolRegistry() { return mockToolRegistry; }, getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), } as unknown as Config; mockContext = { config: mockConfig, }; vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool); }); describe('MemoryCommand', () => { it('delegates to ShowMemoryCommand', async () => { const command = new MemoryCommand(); mockShowMemory.mockReturnValue({ type: 'message', messageType: 'info', content: 'showing memory', }); const response = await command.execute(mockContext, []); expect(response.data).toBe('showing memory'); expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); }); }); describe('ShowMemoryCommand', () => { it('executes showMemory and returns the content', async () => { const command = new ShowMemoryCommand(); mockShowMemory.mockReturnValue({ type: 'message', messageType: 'info', content: 'test memory content', }); const response = await command.execute(mockContext, []); expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); expect(response.name).toBe('memory show'); expect(response.data).toBe('test memory content'); }); }); describe('RefreshMemoryCommand', () => { it('executes refreshMemory and returns the content', async () => { const command = new RefreshMemoryCommand(); mockRefreshMemory.mockResolvedValue({ type: 'message', messageType: 'info', content: 'memory refreshed', }); const response = await command.execute(mockContext, []); expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); expect(response.name).toBe('memory refresh'); expect(response.data).toBe('memory refreshed'); }); }); describe('ListMemoryCommand', () => { it('executes listMemoryFiles and returns the content', async () => { const command = new ListMemoryCommand(); mockListMemoryFiles.mockReturnValue({ type: 'message', messageType: 'info', content: 'file1.md\nfile2.md', }); const response = await command.execute(mockContext, []); expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config); expect(response.name).toBe('memory list'); expect(response.data).toBe('file1.md\nfile2.md'); }); }); describe('AddMemoryCommand', () => { it('returns message content if addMemory returns a message', async () => { const command = new AddMemoryCommand(); mockAddMemory.mockReturnValue({ type: 'message', messageType: 'error', content: 'error message', }); const response = await command.execute(mockContext, []); expect(mockAddMemory).toHaveBeenCalledWith(''); expect(response.name).toBe('memory add'); expect(response.data).toBe('error message'); }); it('executes the save_memory tool if found', async () => { const command = new AddMemoryCommand(); const fact = 'this is a new fact'; mockAddMemory.mockReturnValue({ type: 'tool', toolName: 'save_memory', toolArgs: { fact }, }); const response = await command.execute(mockContext, [ 'this', 'is', 'a', 'new', 'fact', ]); expect(mockAddMemory).toHaveBeenCalledWith(fact); expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory'); expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith( { fact }, expect.any(AbortSignal), undefined, { shellExecutionConfig: { sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }, sandboxManager: undefined, }, }, ); expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); expect(response.name).toBe('memory add'); expect(response.data).toBe(`Added memory: "${fact}"`); }); it('returns an error if the tool is not found', async () => { const command = new AddMemoryCommand(); const fact = 'another fact'; mockAddMemory.mockReturnValue({ type: 'tool', toolName: 'save_memory', toolArgs: { fact }, }); vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined); const response = await command.execute(mockContext, ['another', 'fact']); expect(response.name).toBe('memory add'); expect(response.data).toBe('Error: Tool save_memory not found.'); }); }); }); ================================================ FILE: packages/a2a-server/src/commands/memory.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { addMemory, listMemoryFiles, refreshMemory, showMemory, } from '@google/gemini-cli-core'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; import type { AgentLoopContext } from '@google/gemini-cli-core'; const DEFAULT_SANITIZATION_CONFIG = { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }; export class MemoryCommand implements Command { readonly name = 'memory'; readonly description = 'Manage memory.'; readonly subCommands = [ new ShowMemoryCommand(), new RefreshMemoryCommand(), new ListMemoryCommand(), new AddMemoryCommand(), ]; readonly topLevel = true; readonly requiresWorkspace = true; async execute( context: CommandContext, _: string[], ): Promise { return new ShowMemoryCommand().execute(context, _); } } export class ShowMemoryCommand implements Command { readonly name = 'memory show'; readonly description = 'Shows the current memory contents.'; async execute( context: CommandContext, _: string[], ): Promise { const result = showMemory(context.config); return { name: this.name, data: result.content }; } } export class RefreshMemoryCommand implements Command { readonly name = 'memory refresh'; readonly description = 'Refreshes the memory from the source.'; async execute( context: CommandContext, _: string[], ): Promise { const result = await refreshMemory(context.config); return { name: this.name, data: result.content }; } } export class ListMemoryCommand implements Command { readonly name = 'memory list'; readonly description = 'Lists the paths of the GEMINI.md files in use.'; async execute( context: CommandContext, _: string[], ): Promise { const result = listMemoryFiles(context.config); return { name: this.name, data: result.content }; } } export class AddMemoryCommand implements Command { readonly name = 'memory add'; readonly description = 'Add content to the memory.'; async execute( context: CommandContext, args: string[], ): Promise { const textToAdd = args.join(' ').trim(); const result = addMemory(textToAdd); if (result.type === 'message') { return { name: this.name, data: result.content }; } const loopContext: AgentLoopContext = context.config; const toolRegistry = loopContext.toolRegistry; const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); const signal = abortController.signal; await tool.buildAndExecute(result.toolArgs, signal, undefined, { shellExecutionConfig: { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, sandboxManager: loopContext.sandboxManager, }, }); await refreshMemory(context.config); return { name: this.name, data: `Added memory: "${textToAdd}"`, }; } else { return { name: this.name, data: `Error: Tool ${result.toolName} not found.`, }; } } } ================================================ FILE: packages/a2a-server/src/commands/restore.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RestoreCommand, ListCheckpointsCommand } from './restore.js'; import type { CommandContext } from './types.js'; import type { Config } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; beforeEach(() => { vi.clearAllMocks(); }); const mockPerformRestore = vi.hoisted(() => vi.fn()); const mockLoggerInfo = vi.hoisted(() => vi.fn()); const mockGetCheckpointInfoList = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, performRestore: mockPerformRestore, getCheckpointInfoList: mockGetCheckpointInfoList, }; }); const mockFs = vi.hoisted(() => ({ readFile: vi.fn(), readdir: vi.fn(), mkdir: vi.fn(), })); vi.mock('node:fs/promises', () => mockFs); vi.mock('../utils/logger.js', () => ({ logger: { info: mockLoggerInfo, }, })); describe('RestoreCommand', () => { const mockConfig = { config: createMockConfig() as Config, git: {}, } as CommandContext; it('should return error if no checkpoint name is provided', async () => { const command = new RestoreCommand(); const result = await command.execute(mockConfig, []); expect(result.data).toEqual({ type: 'message', messageType: 'error', content: 'Please provide a checkpoint name to restore.', }); }); it('should restore a checkpoint when a valid file is provided', async () => { const command = new RestoreCommand(); const toolCallData = { toolCall: { name: 'test-tool', args: {}, }, history: [], clientHistory: [], commitHash: '123', }; mockFs.readFile.mockResolvedValue(JSON.stringify(toolCallData)); const restoreContent = { type: 'message', messageType: 'info', content: 'Restored', }; mockPerformRestore.mockReturnValue( (async function* () { yield restoreContent; })(), ); const result = await command.execute(mockConfig, ['checkpoint1.json']); expect(result.data).toEqual([restoreContent]); }); it('should show "file not found" error for a non-existent checkpoint', async () => { const command = new RestoreCommand(); const error = new Error('File not found'); (error as NodeJS.ErrnoException).code = 'ENOENT'; mockFs.readFile.mockRejectedValue(error); const result = await command.execute(mockConfig, ['checkpoint2.json']); expect(result.data).toEqual({ type: 'message', messageType: 'error', content: 'File not found: checkpoint2.json', }); }); it('should handle invalid JSON in checkpoint file', async () => { const command = new RestoreCommand(); mockFs.readFile.mockResolvedValue('invalid json'); const result = await command.execute(mockConfig, ['checkpoint1.json']); expect((result.data as { content: string }).content).toContain( 'An unexpected error occurred during restore.', ); }); }); describe('ListCheckpointsCommand', () => { const mockConfig = { config: createMockConfig() as Config, } as CommandContext; it('should list all available checkpoints', async () => { const command = new ListCheckpointsCommand(); const checkpointInfo = [{ file: 'checkpoint1.json', description: 'Test' }]; mockFs.readdir.mockResolvedValue(['checkpoint1.json']); mockFs.readFile.mockResolvedValue( JSON.stringify({ toolCall: { name: 'Test', args: {} } }), ); mockGetCheckpointInfoList.mockReturnValue(checkpointInfo); const result = await command.execute(mockConfig); expect((result.data as { content: string }).content).toEqual( JSON.stringify(checkpointInfo), ); }); it('should handle errors when listing checkpoints', async () => { const command = new ListCheckpointsCommand(); mockFs.readdir.mockRejectedValue(new Error('Read error')); const result = await command.execute(mockConfig); expect((result.data as { content: string }).content).toContain( 'An unexpected error occurred while listing checkpoints.', ); }); }); ================================================ FILE: packages/a2a-server/src/commands/restore.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getCheckpointInfoList, getToolCallDataSchema, isNodeError, performRestore, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; export class RestoreCommand implements Command { readonly name = 'restore'; readonly description = 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created'; readonly topLevel = true; readonly requiresWorkspace = true; readonly subCommands = [new ListCheckpointsCommand()]; async execute( context: CommandContext, args: string[], ): Promise { const { config, git: gitService } = context; const argsStr = args.join(' '); try { if (!argsStr) { return { name: this.name, data: { type: 'message', messageType: 'error', content: 'Please provide a checkpoint name to restore.', }, }; } const selectedFile = argsStr.endsWith('.json') ? argsStr : `${argsStr}.json`; const checkpointDir = config.storage.getProjectTempCheckpointsDir(); const filePath = path.join(checkpointDir, selectedFile); let data: string; try { data = await fs.readFile(filePath, 'utf-8'); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { return { name: this.name, data: { type: 'message', messageType: 'error', content: `File not found: ${selectedFile}`, }, }; } throw error; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const toolCallData = JSON.parse(data); const ToolCallDataSchema = getToolCallDataSchema(); const parseResult = ToolCallDataSchema.safeParse(toolCallData); if (!parseResult.success) { return { name: this.name, data: { type: 'message', messageType: 'error', content: 'Checkpoint file is invalid or corrupted.', }, }; } const restoreResultGenerator = performRestore( parseResult.data, gitService, ); const restoreResult = []; for await (const result of restoreResultGenerator) { restoreResult.push(result); } return { name: this.name, data: restoreResult, }; } catch (_error) { return { name: this.name, data: { type: 'message', messageType: 'error', content: 'An unexpected error occurred during restore.', }, }; } } } export class ListCheckpointsCommand implements Command { readonly name = 'restore list'; readonly description = 'Lists all available checkpoints.'; readonly topLevel = false; async execute(context: CommandContext): Promise { const { config } = context; try { const checkpointDir = config.storage.getProjectTempCheckpointsDir(); await fs.mkdir(checkpointDir, { recursive: true }); const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); const checkpointFiles = new Map(); for (const file of jsonFiles) { const filePath = path.join(checkpointDir, file); const data = await fs.readFile(filePath, 'utf-8'); checkpointFiles.set(file, data); } const checkpointInfoList = getCheckpointInfoList(checkpointFiles); return { name: this.name, data: { type: 'message', messageType: 'info', content: JSON.stringify(checkpointInfoList), }, }; } catch (_error) { return { name: this.name, data: { type: 'message', messageType: 'error', content: 'An unexpected error occurred while listing checkpoints.', }, }; } } } ================================================ FILE: packages/a2a-server/src/commands/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server'; import type { Config, GitService } from '@google/gemini-cli-core'; export interface CommandContext { config: Config; git?: GitService; agentExecutor?: AgentExecutor; eventBus?: ExecutionEventBus; } export interface CommandArgument { readonly name: string; readonly description: string; readonly isRequired?: boolean; } export interface Command { readonly name: string; readonly description: string; readonly arguments?: CommandArgument[]; readonly subCommands?: Command[]; readonly topLevel?: boolean; readonly requiresWorkspace?: boolean; readonly streaming?: boolean; execute( config: CommandContext, args: string[], ): Promise; } export interface CommandExecutionResponse { readonly name: string; readonly data: unknown; } ================================================ FILE: packages/a2a-server/src/config/config.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as path from 'node:path'; import { loadConfig } from './config.js'; import type { Settings } from './settings.js'; import { type ExtensionLoader, FileDiscoveryService, getCodeAssistServer, Config, ExperimentFlags, fetchAdminControlsOnce, type FetchAdminControlsResponse, AuthType, isHeadlessMode, FatalAuthenticationError, PolicyDecision, PRIORITY_YOLO_ALLOW_ALL, } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, Config: vi.fn().mockImplementation((params) => { const mockConfig = { ...params, initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { [actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: { boolValue: false, }, }, }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }; return mockConfig; }), loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: { global: '', extension: '', project: '' }, fileCount: 0, filePaths: [], }), startupProfiler: { flush: vi.fn(), }, isHeadlessMode: vi.fn().mockReturnValue(false), FileDiscoveryService: vi.fn(), getCodeAssistServer: vi.fn(), fetchAdminControlsOnce: vi.fn(), coreEvents: { emitAdminSettingsChanged: vi.fn(), }, }; }); vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); describe('loadConfig', () => { const mockSettings = {} as Settings; const mockExtensionLoader = {} as ExtensionLoader; const taskId = 'test-task-id'; beforeEach(() => { vi.clearAllMocks(); vi.stubEnv('GEMINI_API_KEY', 'test-key'); }); afterEach(() => { vi.unstubAllEnvs(); }); describe('admin settings overrides', () => { it('should not fetch admin controls if experiment is disabled', async () => { await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(fetchAdminControlsOnce).not.toHaveBeenCalled(); }); it('should pass clientName as a2a-server to Config', async () => { await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ clientName: 'a2a-server', }), ); }); describe('when admin controls experiment is enabled', () => { beforeEach(() => { // We need to cast to any here to modify the mock implementation // eslint-disable-next-line @typescript-eslint/no-explicit-any (Config as any).mockImplementation((params: unknown) => { const mockConfig = { ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { [ExperimentFlags.ENABLE_ADMIN_CONTROLS]: { boolValue: true, }, }, }), getRemoteAdminSettings: vi.fn().mockReturnValue({}), setRemoteAdminSettings: vi.fn(), }; return mockConfig; }); }); it('should fetch admin controls and apply them', async () => { const mockAdminSettings: FetchAdminControlsResponse = { mcpSetting: { mcpEnabled: false, }, cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false, }, }, strictModeDisabled: false, }; vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenLastCalledWith( expect.objectContaining({ disableYoloMode: !mockAdminSettings.strictModeDisabled, mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, extensionsEnabled: mockAdminSettings.cliFeatureSetting?.extensionsSetting ?.extensionsEnabled, }), ); }); it('should treat unset admin settings as false when admin settings are passed', async () => { const mockAdminSettings: FetchAdminControlsResponse = { mcpSetting: { mcpEnabled: true, }, }; vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenLastCalledWith( expect.objectContaining({ disableYoloMode: !false, mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, extensionsEnabled: undefined, }), ); }); it('should not pass default unset admin settings when no admin settings are present', async () => { const mockAdminSettings: FetchAdminControlsResponse = {}; vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({})); }); it('should fetch admin controls using the code assist server when available', async () => { const mockAdminSettings: FetchAdminControlsResponse = { mcpSetting: { mcpEnabled: true, }, strictModeDisabled: true, }; const mockCodeAssistServer = { projectId: 'test-project' }; vi.mocked(getCodeAssistServer).mockReturnValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any mockCodeAssistServer as any, ); vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(fetchAdminControlsOnce).toHaveBeenCalledWith( mockCodeAssistServer, true, ); expect(Config).toHaveBeenLastCalledWith( expect.objectContaining({ disableYoloMode: !mockAdminSettings.strictModeDisabled, mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, extensionsEnabled: undefined, }), ); }); }); }); it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => { const testPath = '/tmp/ignore'; vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ testPath, ]); }); it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => { const testPath = '/settings/ignore'; const settings: Settings = { fileFiltering: { customIgnoreFilePaths: [testPath], }, }; const config = await loadConfig(settings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ testPath, ]); }); it('should merge customIgnoreFilePaths from settings and env var', async () => { const envPath = '/env/ignore'; const settingsPath = '/settings/ignore'; vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath); const settings: Settings = { fileFiltering: { customIgnoreFilePaths: [settingsPath], }, }; const config = await loadConfig(settings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ settingsPath, envPath, ]); }); it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { const paths = ['/path/one', '/path/two']; vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter)); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); }); it('should have empty customIgnoreFilePaths when both are missing', async () => { const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]); }); it('should initialize FileDiscoveryService with correct options', async () => { const testPath = '/tmp/ignore'; vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const settings: Settings = { fileFiltering: { respectGitIgnore: false, }, }; await loadConfig(settings, mockExtensionLoader, taskId); expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), { respectGitIgnore: false, respectGeminiIgnore: undefined, customIgnoreFilePaths: [testPath], }); }); describe('tool configuration', () => { it('should pass V1 allowedTools to Config properly', async () => { const settings: Settings = { allowedTools: ['shell', 'edit'], }; await loadConfig(settings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ allowedTools: ['shell', 'edit'], }), ); }); it('should pass V2 tools.allowed to Config properly', async () => { const settings: Settings = { tools: { allowed: ['shell', 'fetch'], }, }; await loadConfig(settings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ allowedTools: ['shell', 'fetch'], }), ); }); it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => { const settings: Settings = { allowedTools: ['v1-tool'], tools: { allowed: ['v2-tool'], }, }; await loadConfig(settings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ allowedTools: ['v1-tool'], }), ); }); it('should pass enableAgents to Config constructor', async () => { const settings: Settings = { experimental: { enableAgents: false, }, }; await loadConfig(settings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ enableAgents: false, }), ); }); it('should default enableAgents to true when not provided', async () => { await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ enableAgents: true, }), ); }); describe('interactivity', () => { it('should set interactive true when not headless', async () => { vi.mocked(isHeadlessMode).mockReturnValue(false); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ interactive: true, enableInteractiveShell: true, }), ); }); it('should set interactive false when headless', async () => { vi.mocked(isHeadlessMode).mockReturnValue(true); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ interactive: false, enableInteractiveShell: false, }), ); }); }); describe('YOLO mode', () => { it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => { vi.stubEnv('GEMINI_YOLO_MODE', 'true'); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ approvalMode: 'yolo', policyEngineConfig: expect.objectContaining({ rules: expect.arrayContaining([ expect.objectContaining({ decision: PolicyDecision.ALLOW, priority: PRIORITY_YOLO_ALLOW_ALL, modes: ['yolo'], allowRedirection: true, }), ]), }), }), ); }); it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => { vi.stubEnv('GEMINI_YOLO_MODE', 'false'); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ approvalMode: 'default', policyEngineConfig: expect.objectContaining({ rules: [], }), }), ); }); }); describe('authentication fallback', () => { beforeEach(() => { vi.stubEnv('USE_CCPA', 'true'); vi.stubEnv('GEMINI_API_KEY', ''); }); afterEach(() => { vi.unstubAllEnvs(); }); it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { vi.stubEnv('CLOUD_SHELL', 'true'); vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('Non-interactive session'); } return Promise.resolve(); }); // Update the mock implementation for this test vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(refreshAuthMock).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => { vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('Non-interactive session'); } return Promise.resolve(); }); vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await expect( loadConfig(mockSettings, mockExtensionLoader, taskId), ).rejects.toThrow('Non-interactive session'); expect(refreshAuthMock).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => { vi.stubEnv('CLOUD_SHELL', 'true'); vi.mocked(isHeadlessMode).mockReturnValue(true); const refreshAuthMock = vi.fn().mockResolvedValue(undefined); vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(refreshAuthMock).not.toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => { vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true'); vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless const refreshAuthMock = vi.fn().mockResolvedValue(undefined); vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(refreshAuthMock).not.toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => { vi.mocked(isHeadlessMode).mockReturnValue(true); const refreshAuthMock = vi.fn().mockResolvedValue(undefined); vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await expect( loadConfig(mockSettings, mockExtensionLoader, taskId), ).rejects.toThrow( 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.', ); expect(refreshAuthMock).not.toHaveBeenCalled(); }); it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => { vi.stubEnv('CLOUD_SHELL', 'true'); vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('OAuth failed'); } if (authType === AuthType.COMPUTE_ADC) { throw new Error('ADC failed'); } return Promise.resolve(); }); vi.mocked(Config).mockImplementation( (params: unknown) => ({ ...(params as object), initialize: vi.fn(), waitForMcpInit: vi.fn(), refreshAuth: refreshAuthMock, getExperiments: vi.fn().mockReturnValue({ flags: {} }), getRemoteAdminSettings: vi.fn(), setRemoteAdminSettings: vi.fn(), }) as unknown as Config, ); await expect( loadConfig(mockSettings, mockExtensionLoader, taskId), ).rejects.toThrow( 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed', ); }); }); }); }); ================================================ FILE: packages/a2a-server/src/config/config.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; import { AuthType, Config, FileDiscoveryService, ApprovalMode, loadServerHierarchicalMemory, GEMINI_DIR, DEFAULT_GEMINI_EMBEDDING_MODEL, startupProfiler, PREVIEW_GEMINI_MODEL, homedir, GitService, fetchAdminControlsOnce, getCodeAssistServer, ExperimentFlags, isHeadlessMode, FatalAuthenticationError, isCloudShell, PolicyDecision, PRIORITY_YOLO_ALLOW_ALL, type TelemetryTarget, type ConfigParameters, type ExtensionLoader, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; import type { Settings } from './settings.js'; import { type AgentSettings, CoderAgentEvent } from '../types.js'; export async function loadConfig( settings: Settings, extensionLoader: ExtensionLoader, taskId: string, ): Promise { const workspaceDir = process.cwd(); const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; const folderTrust = settings.folderTrust === true || process.env['GEMINI_FOLDER_TRUST'] === 'true'; let checkpointing = process.env['CHECKPOINTING'] ? process.env['CHECKPOINTING'] === 'true' : settings.checkpointing?.enabled; if (checkpointing) { if (!(await GitService.verifyGitAvailability())) { logger.warn( '[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.', ); checkpointing = false; } } const approvalMode = process.env['GEMINI_YOLO_MODE'] === 'true' ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; const configParams: ConfigParameters = { sessionId: taskId, clientName: 'a2a-server', model: PREVIEW_GEMINI_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent targetDir: workspaceDir, // Or a specific directory the agent operates on debugMode: process.env['DEBUG'] === 'true' || false, question: '', // Not used in server mode directly like CLI coreTools: settings.coreTools || settings.tools?.core || undefined, excludeTools: settings.excludeTools || settings.tools?.exclude || undefined, allowedTools: settings.allowedTools || settings.tools?.allowed || undefined, showMemoryUsage: settings.showMemoryUsage || false, approvalMode, policyEngineConfig: { rules: approvalMode === ApprovalMode.YOLO ? [ { decision: PolicyDecision.ALLOW, priority: PRIORITY_YOLO_ALLOW_ALL, modes: [ApprovalMode.YOLO], allowRedirection: true, }, ] : [], }, mcpServers: settings.mcpServers, cwd: workspaceDir, telemetry: { enabled: settings.telemetry?.enabled, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion target: settings.telemetry?.target as TelemetryTarget, otlpEndpoint: process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? settings.telemetry?.otlpEndpoint, logPrompts: settings.telemetry?.logPrompts, }, // Git-aware file filtering settings fileFiltering: { respectGitIgnore: settings.fileFiltering?.respectGitIgnore, respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore, enableRecursiveFileSearch: settings.fileFiltering?.enableRecursiveFileSearch, customIgnoreFilePaths: [ ...(settings.fileFiltering?.customIgnoreFilePaths || []), ...(process.env['CUSTOM_IGNORE_FILE_PATHS'] ? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter) : []), ], }, ideMode: false, folderTrust, trustedFolder: true, extensionLoader, checkpointing, interactive: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', enableAgents: settings.experimental?.enableAgents ?? true, }; const fileService = new FileDiscoveryService(workspaceDir, { respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore, respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore, customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths, }); const { memoryContent, fileCount, filePaths } = await loadServerHierarchicalMemory( workspaceDir, [workspaceDir], fileService, extensionLoader, folderTrust, ); configParams.userMemory = memoryContent; configParams.geminiMdFileCount = fileCount; configParams.geminiMdFilePaths = filePaths; // Set an initial config to use to get a code assist server. // This is needed to fetch admin controls. const initialConfig = new Config({ ...configParams, }); const codeAssistServer = getCodeAssistServer(initialConfig); const adminControlsEnabled = initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS] ?.boolValue ?? false; // Initialize final config parameters to the previous parameters. // If no admin controls are needed, these will be used as-is for the final // config. const finalConfigParams = { ...configParams }; if (adminControlsEnabled) { const adminSettings = await fetchAdminControlsOnce( codeAssistServer, adminControlsEnabled, ); // Admin settings are able to be undefined if unset, but if any are present, // we should initialize them all. // If any are present, undefined settings should be treated as if they were // set to false. // If NONE are present, disregard admin settings entirely, and pass the // final config as is. if (Object.keys(adminSettings).length !== 0) { finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled; finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled; finalConfigParams.extensionsEnabled = adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled; } } const config = new Config(finalConfigParams); // Needed to initialize ToolRegistry, and git checkpointing if enabled await config.initialize(); await config.waitForMcpInit(); startupProfiler.flush(config); await refreshAuthentication(config, adcFilePath, 'Config'); return config; } export function setTargetDir(agentSettings: AgentSettings | undefined): string { const originalCWD = process.cwd(); const targetDir = process.env['CODER_AGENT_WORKSPACE_PATH'] ?? (agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent ? agentSettings.workspacePath : undefined); if (!targetDir) { return originalCWD; } logger.info( `[CoderAgentExecutor] Overriding workspace path to: ${targetDir}`, ); try { const resolvedPath = path.resolve(targetDir); process.chdir(resolvedPath); return resolvedPath; } catch (e) { logger.error( `[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`, ); return originalCWD; } } export function loadEnvironment(): void { const envFilePath = findEnvFile(process.cwd()); if (envFilePath) { dotenv.config({ path: envFilePath, override: true }); } } function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); if (fs.existsSync(geminiEnvPath)) { return geminiEnvPath; } const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { return envPath; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { // check .env under home as fallback, again preferring gemini-specific .env const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env'); if (fs.existsSync(homeGeminiEnvPath)) { return homeGeminiEnvPath; } const homeEnvPath = path.join(homedir(), '.env'); if (fs.existsSync(homeEnvPath)) { return homeEnvPath; } return null; } currentDir = parentDir; } } async function refreshAuthentication( config: Config, adcFilePath: string | undefined, logPrefix: string, ): Promise { if (process.env['USE_CCPA']) { logger.info(`[${logPrefix}] Using CCPA Auth:`); try { if (adcFilePath) { path.resolve(adcFilePath); } } catch (e) { logger.error( `[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`, ); } const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; const isHeadless = isHeadlessMode(); const shouldSkipOauth = isHeadless || useComputeAdc; if (shouldSkipOauth) { if (isCloudShell() || useComputeAdc) { logger.info( `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`, ); try { await config.refreshAuth(AuthType.COMPUTE_ADC); logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); } catch (adcError) { const adcMessage = adcError instanceof Error ? adcError.message : String(adcError); throw new FatalAuthenticationError( `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`, ); } } else { throw new FatalAuthenticationError( `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`, ); } } else { try { await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); } catch (e) { if ( e instanceof FatalAuthenticationError && (isCloudShell() || useComputeAdc) ) { logger.warn( `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`, ); try { await config.refreshAuth(AuthType.COMPUTE_ADC); logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); } catch (adcError) { logger.error( `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, ); const originalMessage = e instanceof Error ? e.message : String(e); const adcMessage = adcError instanceof Error ? adcError.message : String(adcError); throw new FatalAuthenticationError( `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, ); } } else { throw e; } } } logger.info( `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`, ); } else if (process.env['GEMINI_API_KEY']) { logger.info(`[${logPrefix}] Using Gemini API Key`); await config.refreshAuth(AuthType.USE_GEMINI); } else { const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`; logger.error(errorMessage); throw new Error(errorMessage); } } ================================================ FILE: packages/a2a-server/src/config/extension.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // Copied exactly from packages/cli/src/config/extension.ts, last PR #1026 import { GEMINI_DIR, type MCPServerConfig, type ExtensionInstallMetadata, type GeminiCLIExtension, homedir, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { logger } from '../utils/logger.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; /** * Extension definition as written to disk in gemini-extension.json files. * This should *not* be referenced outside of the logic for reading files. * If information is required for manipulating extensions (load, unload, update) * outside of the loading process that data needs to be stored on the * GeminiCLIExtension class defined in Core. */ interface ExtensionConfig { name: string; version: string; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; } export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] { const allExtensions = [ ...loadExtensionsFromDir(workspaceDir), ...loadExtensionsFromDir(homedir()), ]; const uniqueExtensions: GeminiCLIExtension[] = []; const seenNames = new Set(); for (const extension of allExtensions) { if (!seenNames.has(extension.name)) { logger.info( `Loading extension: ${extension.name} (version: ${extension.version})`, ); uniqueExtensions.push(extension); seenNames.add(extension.name); } } return uniqueExtensions; } function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] { const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME); if (!fs.existsSync(extensionsDir)) { return []; } const extensions: GeminiCLIExtension[] = []; for (const subdir of fs.readdirSync(extensionsDir)) { const extensionDir = path.join(extensionsDir, subdir); const extension = loadExtension(extensionDir); if (extension != null) { extensions.push(extension); } } return extensions; } function loadExtension(extensionDir: string): GeminiCLIExtension | null { if (!fs.statSync(extensionDir).isDirectory()) { logger.error( `Warning: unexpected file ${extensionDir} in extensions directory.`, ); return null; } const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { logger.error( `Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`, ); return null; } try { const configContent = fs.readFileSync(configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = JSON.parse(configContent) as ExtensionConfig; if (!config.name || !config.version) { logger.error( `Invalid extension config in ${configFilePath}: missing name or version.`, ); return null; } const installMetadata = loadInstallMetadata(extensionDir); const contextFiles = getContextFileNames(config) .map((contextFileName) => path.join(extensionDir, contextFileName)) .filter((contextFilePath) => fs.existsSync(contextFilePath)); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { name: config.name, version: config.version, path: extensionDir, contextFiles, installMetadata, mcpServers: config.mcpServers, excludeTools: config.excludeTools, isActive: true, // Barring any other signals extensions should be considered Active. } as GeminiCLIExtension; } catch (e) { logger.error( `Warning: error parsing extension config in ${configFilePath}: ${e}`, ); return null; } } function getContextFileNames(config: ExtensionConfig): string[] { if (!config.contextFileName) { return ['GEMINI.md']; } else if (!Array.isArray(config.contextFileName)) { return [config.contextFileName]; } return config.contextFileName; } export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); try { const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; return metadata; } catch (e) { logger.warn( `Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`, ); return undefined; } } ================================================ FILE: packages/a2a-server/src/config/settings.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { loadSettings, USER_SETTINGS_PATH } from './settings.js'; import { debugLogger } from '@google/gemini-cli-core'; const mocks = vi.hoisted(() => { const suffix = Math.random().toString(36).slice(2); return { suffix, }; }); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); const path = await import('node:path'); return { ...actual, homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); const path = await import('node:path'); const os = await import('node:os'); return { ...actual, GEMINI_DIR: '.gemini', debugLogger: { error: vi.fn(), }, getErrorMessage: (error: unknown) => String(error), homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`), }; }); describe('loadSettings', () => { const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`); const mockWorkspaceDir = path.join( os.tmpdir(), `gemini-workspace-${mocks.suffix}`, ); const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini'); const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini'); beforeEach(() => { vi.clearAllMocks(); // Create the directories using the real fs if (!fs.existsSync(mockGeminiHomeDir)) { fs.mkdirSync(mockGeminiHomeDir, { recursive: true }); } if (!fs.existsSync(mockGeminiWorkspaceDir)) { fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true }); } // Clean up settings files before each test if (fs.existsSync(USER_SETTINGS_PATH)) { fs.rmSync(USER_SETTINGS_PATH); } const workspaceSettingsPath = path.join( mockGeminiWorkspaceDir, 'settings.json', ); if (fs.existsSync(workspaceSettingsPath)) { fs.rmSync(workspaceSettingsPath); } }); afterEach(() => { try { if (fs.existsSync(mockHomeDir)) { fs.rmSync(mockHomeDir, { recursive: true, force: true }); } if (fs.existsSync(mockWorkspaceDir)) { fs.rmSync(mockWorkspaceDir, { recursive: true, force: true }); } } catch (e) { debugLogger.error('Failed to cleanup temp dirs', e); } vi.restoreAllMocks(); }); it('should load other top-level settings correctly', () => { const settings = { showMemoryUsage: true, coreTools: ['tool1', 'tool2'], mcpServers: { server1: { command: 'cmd', args: ['arg'], }, }, fileFiltering: { respectGitIgnore: true, }, }; fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); const result = loadSettings(mockWorkspaceDir); expect(result.showMemoryUsage).toBe(true); expect(result.coreTools).toEqual(['tool1', 'tool2']); expect(result.mcpServers).toHaveProperty('server1'); expect(result.fileFiltering?.respectGitIgnore).toBe(true); }); it('should load experimental settings correctly', () => { const settings = { experimental: { enableAgents: true, }, }; fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); const result = loadSettings(mockWorkspaceDir); expect(result.experimental?.enableAgents).toBe(true); }); it('should overwrite top-level settings from workspace (shallow merge)', () => { const userSettings = { showMemoryUsage: false, fileFiltering: { respectGitIgnore: true, enableRecursiveFileSearch: true, }, }; fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); const workspaceSettings = { showMemoryUsage: true, fileFiltering: { respectGitIgnore: false, }, }; const workspaceSettingsPath = path.join( mockGeminiWorkspaceDir, 'settings.json', ); fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); const result = loadSettings(mockWorkspaceDir); // Primitive value overwritten expect(result.showMemoryUsage).toBe(true); // Object value completely replaced (shallow merge behavior) expect(result.fileFiltering?.respectGitIgnore).toBe(false); expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined(); }); }); ================================================ FILE: packages/a2a-server/src/config/settings.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { type MCPServerConfig, debugLogger, GEMINI_DIR, getErrorMessage, type TelemetrySettings, homedir, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR); export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); // TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json). // This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings, // similar to how packages/cli/src/config/settings.ts handles it. export interface Settings { mcpServers?: Record; coreTools?: string[]; excludeTools?: string[]; allowedTools?: string[]; tools?: { allowed?: string[]; exclude?: string[]; core?: string[]; }; telemetry?: TelemetrySettings; showMemoryUsage?: boolean; checkpointing?: CheckpointingSettings; folderTrust?: boolean; general?: { previewFeatures?: boolean; }; // Git-aware file filtering settings fileFiltering?: { respectGitIgnore?: boolean; respectGeminiIgnore?: boolean; enableRecursiveFileSearch?: boolean; customIgnoreFilePaths?: string[]; }; experimental?: { enableAgents?: boolean; }; } export interface SettingsError { message: string; path: string; } export interface CheckpointingSettings { enabled?: boolean; } /** * Loads settings from user and workspace directories. * Project settings override user settings. * * How is it different to gemini-cli/cli: Returns already merged settings rather * than `LoadedSettings` (unnecessary since we are not modifying users * settings.json). */ export function loadSettings(workspaceDir: string): Settings { let userSettings: Settings = {}; let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; // Load user settings try { if (fs.existsSync(USER_SETTINGS_PATH)) { const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const parsedUserSettings = JSON.parse( stripJsonComments(userContent), ) as Settings; userSettings = resolveEnvVarsInObject(parsedUserSettings); } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: USER_SETTINGS_PATH, }); } const workspaceSettingsPath = path.join( workspaceDir, GEMINI_DIR, 'settings.json', ); // Load workspace settings try { if (fs.existsSync(workspaceSettingsPath)) { const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const parsedWorkspaceSettings = JSON.parse( stripJsonComments(projectContent), ) as Settings; workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: workspaceSettingsPath, }); } if (settingsErrors.length > 0) { debugLogger.error('Errors loading settings:'); for (const error of settingsErrors) { debugLogger.error(` Path: ${error.path}`); debugLogger.error(` Message: ${error.message}`); } } // If there are overlapping keys, the values of workspaceSettings will // override values from userSettings return { ...userSettings, ...workspaceSettings, }; } function resolveEnvVarsInString(value: string): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const varName = varName1 || varName2; if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]; } return match; }); } function resolveEnvVarsInObject(obj: T): T { if ( obj === null || obj === undefined || typeof obj === 'boolean' || typeof obj === 'number' ) { return obj; } if (typeof obj === 'string') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return resolveEnvVarsInString(obj) as unknown as T; } if (Array.isArray(obj)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; } if (typeof obj === 'object') { const newObj = { ...obj } as T; for (const key in newObj) { if (Object.prototype.hasOwnProperty.call(newObj, key)) { newObj[key] = resolveEnvVarsInObject(newObj[key]); } } return newObj; } return obj; } ================================================ FILE: packages/a2a-server/src/http/app.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { GeminiEventType, ApprovalMode, type Config, type ToolCallConfirmationDetails, } from '@google/gemini-cli-core'; import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, } from '@a2a-js/sdk'; import express from 'express'; import type { Server } from 'node:http'; import request from 'supertest'; import { afterAll, afterEach, beforeEach, beforeAll, describe, expect, it, vi, } from 'vitest'; import { createApp, main } from './app.js'; import { commandRegistry } from '../commands/command-registry.js'; import { assertUniqueFinalEventIsLast, assertTaskCreationAndWorkingStatus, createStreamMessageRequest, createMockConfig, } from '../utils/testing_utils.js'; // Import MockTool from specific path to avoid vitest dependency in main core bundle import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; import type { Command, CommandContext } from '../commands/types.js'; const mockToolConfirmationFn = async () => ({}) as unknown as ToolCallConfirmationDetails; const streamToSSEEvents = ( stream: string, ): SendStreamingMessageSuccessResponse[] => stream .split('\n\n') .filter(Boolean) // Remove empty strings from trailing newlines .map((chunk) => { const dataLine = chunk .split('\n') .find((line) => line.startsWith('data: ')); if (!dataLine) { throw new Error(`Invalid SSE chunk found: "${chunk}"`); } return JSON.parse(dataLine.substring(6)); }); // Mock the logger to avoid polluting test output // Comment out to debug tests vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); let config: Config; const getToolRegistrySpy = vi.fn().mockReturnValue({ getTool: vi.fn(), getAllToolNames: vi.fn().mockReturnValue([]), getAllTools: vi.fn().mockReturnValue([]), getToolsByServer: vi.fn().mockReturnValue([]), }); const getApprovalModeSpy = vi.fn(); const getShellExecutionConfigSpy = vi.fn(); const getExtensionsSpy = vi.fn(); vi.mock('../config/config.js', async () => { const actual = await vi.importActual('../config/config.js'); return { ...actual, loadConfig: vi.fn().mockImplementation(async () => { const mockConfig = createMockConfig({ getToolRegistry: getToolRegistrySpy, getApprovalMode: getApprovalModeSpy, getShellExecutionConfig: getShellExecutionConfigSpy, getExtensions: getExtensionsSpy, }); config = mockConfig as Config; return config; }), }; }); // Mock the GeminiClient to avoid actual API calls const sendMessageStreamSpy = vi.fn(); vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, GeminiClient: vi.fn().mockImplementation(() => ({ sendMessageStream: sendMessageStreamSpy, getUserTier: vi.fn().mockReturnValue('free'), initialize: vi.fn(), })), performRestore: vi.fn(), }; }); describe('E2E Tests', () => { let app: express.Express; let server: Server; beforeAll(async () => { app = await createApp(); server = app.listen(0); // Listen on a random available port }); beforeEach(() => { getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT); }); afterAll( () => new Promise((resolve) => { server.close(() => { resolve(); }); }), ); afterEach(() => { vi.clearAllMocks(); }); it('should create a new task and stream status updates (text-content) via POST /', async () => { sendMessageStreamSpy.mockImplementation(async function* () { yield* [{ type: 'content', value: 'Hello how are you?' }]; }); const agent = request.agent(app); const res = await agent .post('/') .send(createStreamMessageRequest('hello', 'a2a-test-message')) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // Status update: text-content const textContentEvent = events[2].result as TaskStatusUpdateEvent; expect(textContentEvent.kind).toBe('status-update'); expect(textContentEvent.status.state).toBe('working'); expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'text-content', }); expect(textContentEvent.status.message?.parts).toMatchObject([ { kind: 'text', text: 'Hello how are you?' }, ]); // Status update: input-required (final) const finalEvent = events[3].result as TaskStatusUpdateEvent; expect(finalEvent.kind).toBe('status-update'); expect(finalEvent.status?.state).toBe('input-required'); expect(finalEvent.final).toBe(true); assertUniqueFinalEventIsLast(events); expect(events.length).toBe(4); }); it('should create a new task, schedule a tool call, and wait for approval', async () => { // First call yields the tool request sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [ { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id', name: 'test-tool', args: {}, }, }, ]; }); // Subsequent calls yield nothing sendMessageStreamSpy.mockImplementation(async function* () { yield* []; }); const mockTool = new MockTool({ name: 'test-tool', shouldConfirmExecute: vi.fn(mockToolConfirmationFn), }); getToolRegistrySpy.mockReturnValue({ getAllTools: vi.fn().mockReturnValue([mockTool]), getToolsByServer: vi.fn().mockReturnValue([]), getTool: vi.fn().mockReturnValue(mockTool), }); const agent = request.agent(app); const res = await agent .post('/') .send(createStreamMessageRequest('run a tool', 'a2a-tool-test-message')) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // Status update: working const workingEvent2 = events[2].result as TaskStatusUpdateEvent; expect(workingEvent2.kind).toBe('status-update'); expect(workingEvent2.status.state).toBe('working'); expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({ kind: 'state-change', }); // Status update: tool-call-update const toolCallUpdateEvent = events[3].result as TaskStatusUpdateEvent; expect(toolCallUpdateEvent.kind).toBe('status-update'); expect(toolCallUpdateEvent.status.state).toBe('working'); expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([ { data: { status: 'validating', request: { callId: 'test-call-id' }, }, }, ]); // State update: awaiting_approval update const toolCallConfirmationEvent = events[4].result as TaskStatusUpdateEvent; expect(toolCallConfirmationEvent.kind).toBe('status-update'); expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-confirmation', }); expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([ { data: { status: 'awaiting_approval', request: { callId: 'test-call-id' }, }, }, ]); expect(toolCallConfirmationEvent.status?.state).toBe('working'); assertUniqueFinalEventIsLast(events); expect(events.length).toBe(6); }); it('should handle multiple tool calls in a single turn', async () => { // First call yields the tool request sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [ { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-1', name: 'test-tool-1', args: {}, }, }, { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-2', name: 'test-tool-2', args: {}, }, }, ]; }); // Subsequent calls yield nothing sendMessageStreamSpy.mockImplementation(async function* () { yield* []; }); const mockTool1 = new MockTool({ name: 'test-tool-1', displayName: 'Test Tool 1', shouldConfirmExecute: vi.fn(mockToolConfirmationFn), }); const mockTool2 = new MockTool({ name: 'test-tool-2', displayName: 'Test Tool 2', shouldConfirmExecute: vi.fn(mockToolConfirmationFn), }); getToolRegistrySpy.mockReturnValue({ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), getToolsByServer: vi.fn().mockReturnValue([]), getTool: vi.fn().mockImplementation((name: string) => { if (name === 'test-tool-1') return mockTool1; if (name === 'test-tool-2') return mockTool2; return undefined; }), }); const agent = request.agent(app); const res = await agent .post('/') .send( createStreamMessageRequest( 'run two tools', 'a2a-multi-tool-test-message', ), ) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // Second working update const workingEvent = events[2].result as TaskStatusUpdateEvent; expect(workingEvent.kind).toBe('status-update'); expect(workingEvent.status.state).toBe('working'); // State Update: Validate the first tool call const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent; expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([ { data: { status: 'validating', request: { callId: 'test-call-id-1' }, }, }, ]); // --- Assert the event stream --- // 1. Initial "submitted" status. expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe( 'submitted', ); // 2. "working" status after receiving the user prompt. expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe( 'working', ); // 3. A "state-change" event from the agent. expect(events[2].result.metadata?.['coderAgent']).toMatchObject({ kind: 'state-change', }); // 4. Tool 1 is validating. const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent; expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(toolCallUpdate1.status.message?.parts).toMatchObject([ { data: { request: { callId: 'test-call-id-1' }, status: 'validating', }, }, ]); // 5. Tool 2 is validating. const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent; expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(toolCallUpdate2.status.message?.parts).toMatchObject([ { data: { request: { callId: 'test-call-id-2' }, status: 'validating', }, }, ]); // 6. Tool 1 is awaiting approval. const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent; expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-confirmation', }); expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([ { data: { request: { callId: 'test-call-id-1' }, status: 'awaiting_approval', }, }, ]); // 7. The final event is "input-required". const finalEvent = events[6].result as TaskStatusUpdateEvent; expect(finalEvent.final).toBe(true); expect(finalEvent.status.state).toBe('input-required'); // The scheduler now waits for approval, so no more events are sent. assertUniqueFinalEventIsLast(events); expect(events.length).toBe(7); }); it('should handle multiple tool calls sequentially in YOLO mode', async () => { // Set YOLO mode to auto-approve tools and test sequential execution. getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); // First call yields the tool request sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [ { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-1', name: 'test-tool-1', args: {}, }, }, { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-2', name: 'test-tool-2', args: {}, }, }, ]; }); // Subsequent calls yield nothing, as the tools will "succeed". sendMessageStreamSpy.mockImplementation(async function* () { yield* [{ type: 'content', value: 'All tools executed.' }]; }); const mockTool1 = new MockTool({ name: 'test-tool-1', displayName: 'Test Tool 1', shouldConfirmExecute: vi.fn(mockToolConfirmationFn), execute: vi .fn() .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }), }); const mockTool2 = new MockTool({ name: 'test-tool-2', displayName: 'Test Tool 2', shouldConfirmExecute: vi.fn(mockToolConfirmationFn), execute: vi .fn() .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }), }); getToolRegistrySpy.mockReturnValue({ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), getToolsByServer: vi.fn().mockReturnValue([]), getTool: vi.fn().mockImplementation((name: string) => { if (name === 'test-tool-1') return mockTool1; if (name === 'test-tool-2') return mockTool2; return undefined; }), }); const agent = request.agent(app); const res = await agent .post('/') .send( createStreamMessageRequest( 'run two tools', 'a2a-multi-tool-test-message', ), ) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // --- Assert the sequential execution flow --- const eventStream = events.slice(2).map((e) => { const update = e.result as TaskStatusUpdateEvent; // eslint-disable-next-line @typescript-eslint/no-explicit-any const agentData = update.metadata?.['coderAgent'] as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any const toolData = update.status.message?.parts[0] as any; if (!toolData) { return { kind: agentData.kind }; } return { kind: agentData.kind, status: toolData.data?.status, callId: toolData.data?.request.callId, }; }); const expectedFlow = [ // Initial state change { kind: 'state-change', status: undefined, callId: undefined }, // Tool 1 Lifecycle { kind: 'tool-call-update', status: 'validating', callId: 'test-call-id-1', }, { kind: 'tool-call-update', status: 'scheduled', callId: 'test-call-id-1', }, { kind: 'tool-call-update', status: 'executing', callId: 'test-call-id-1', }, { kind: 'tool-call-update', status: 'success', callId: 'test-call-id-1', }, // Tool 2 Lifecycle { kind: 'tool-call-update', status: 'validating', callId: 'test-call-id-2', }, { kind: 'tool-call-update', status: 'scheduled', callId: 'test-call-id-2', }, { kind: 'tool-call-update', status: 'executing', callId: 'test-call-id-2', }, { kind: 'tool-call-update', status: 'success', callId: 'test-call-id-2', }, // Final updates { kind: 'state-change', status: undefined, callId: undefined }, { kind: 'text-content', status: undefined, callId: undefined }, ]; // Use `toContainEqual` for flexibility if other events are interspersed. expect(eventStream).toEqual(expect.arrayContaining(expectedFlow)); assertUniqueFinalEventIsLast(events); }); it('should handle tool calls that do not require approval', async () => { // First call yields the tool request sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [ { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-no-approval', name: 'test-tool-no-approval', args: {}, }, }, ]; }); // Second call, after the tool runs, yields the final text sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [{ type: 'content', value: 'Tool executed successfully.' }]; }); const mockTool = new MockTool({ name: 'test-tool-no-approval', displayName: 'Test Tool No Approval', execute: vi.fn().mockResolvedValue({ llmContent: 'Tool executed successfully.', returnDisplay: 'Tool executed successfully.', }), }); getToolRegistrySpy.mockReturnValue({ getAllTools: vi.fn().mockReturnValue([mockTool]), getToolsByServer: vi.fn().mockReturnValue([]), getTool: vi.fn().mockReturnValue(mockTool), }); const agent = request.agent(app); const res = await agent .post('/') .send( createStreamMessageRequest( 'run a tool without approval', 'a2a-no-approval-test-message', ), ) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // Status update: working const workingEvent2 = events[2].result as TaskStatusUpdateEvent; expect(workingEvent2.kind).toBe('status-update'); expect(workingEvent2.status.state).toBe('working'); // Status update: tool-call-update (validating) const validatingEvent = events[3].result as TaskStatusUpdateEvent; expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(validatingEvent.status.message?.parts).toMatchObject([ { data: { status: 'validating', request: { callId: 'test-call-id-no-approval' }, }, }, ]); // Status update: tool-call-update (scheduled) const scheduledEvent = events[4].result as TaskStatusUpdateEvent; expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(scheduledEvent.status.message?.parts).toMatchObject([ { data: { status: 'scheduled', request: { callId: 'test-call-id-no-approval' }, }, }, ]); // Status update: tool-call-update (executing) const executingEvent = events[5].result as TaskStatusUpdateEvent; expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(executingEvent.status.message?.parts).toMatchObject([ { data: { status: 'executing', request: { callId: 'test-call-id-no-approval' }, }, }, ]); // Status update: tool-call-update (success) const successEvent = events[6].result as TaskStatusUpdateEvent; expect(successEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(successEvent.status.message?.parts).toMatchObject([ { data: { status: 'success', request: { callId: 'test-call-id-no-approval' }, }, }, ]); // Status update: working (before sending tool result to LLM) const workingEvent3 = events[7].result as TaskStatusUpdateEvent; expect(workingEvent3.kind).toBe('status-update'); expect(workingEvent3.status.state).toBe('working'); // Status update: text-content (final LLM response) const textContentEvent = events[8].result as TaskStatusUpdateEvent; expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'text-content', }); expect(textContentEvent.status.message?.parts).toMatchObject([ { text: 'Tool executed successfully.' }, ]); assertUniqueFinalEventIsLast(events); expect(events.length).toBe(10); }); it('should bypass tool approval in YOLO mode', async () => { // First call yields the tool request sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [ { type: GeminiEventType.ToolCallRequest, value: { callId: 'test-call-id-yolo', name: 'test-tool-yolo', args: {}, }, }, ]; }); // Second call, after the tool runs, yields the final text sendMessageStreamSpy.mockImplementationOnce(async function* () { yield* [{ type: 'content', value: 'Tool executed successfully.' }]; }); // Set approval mode to yolo getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); const mockTool = new MockTool({ name: 'test-tool-yolo', displayName: 'Test Tool YOLO', execute: vi.fn().mockResolvedValue({ llmContent: 'Tool executed successfully.', returnDisplay: 'Tool executed successfully.', }), }); getToolRegistrySpy.mockReturnValue({ getAllTools: vi.fn().mockReturnValue([mockTool]), getToolsByServer: vi.fn().mockReturnValue([]), getTool: vi.fn().mockReturnValue(mockTool), }); const agent = request.agent(app); const res = await agent .post('/') .send( createStreamMessageRequest( 'run a tool in yolo mode', 'a2a-yolo-mode-test-message', ), ) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); assertTaskCreationAndWorkingStatus(events); // Status update: working const workingEvent2 = events[2].result as TaskStatusUpdateEvent; expect(workingEvent2.kind).toBe('status-update'); expect(workingEvent2.status.state).toBe('working'); // Status update: tool-call-update (validating) const validatingEvent = events[3].result as TaskStatusUpdateEvent; expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(validatingEvent.status.message?.parts).toMatchObject([ { data: { status: 'validating', request: { callId: 'test-call-id-yolo' }, }, }, ]); // Status update: tool-call-update (scheduled) const awaitingEvent = events[4].result as TaskStatusUpdateEvent; expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(awaitingEvent.status.message?.parts).toMatchObject([ { data: { status: 'scheduled', request: { callId: 'test-call-id-yolo' }, }, }, ]); // Status update: tool-call-update (executing) const executingEvent = events[5].result as TaskStatusUpdateEvent; expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(executingEvent.status.message?.parts).toMatchObject([ { data: { status: 'executing', request: { callId: 'test-call-id-yolo' }, }, }, ]); // Status update: tool-call-update (success) const successEvent = events[6].result as TaskStatusUpdateEvent; expect(successEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); expect(successEvent.status.message?.parts).toMatchObject([ { data: { status: 'success', request: { callId: 'test-call-id-yolo' }, }, }, ]); // Status update: working (before sending tool result to LLM) const workingEvent3 = events[7].result as TaskStatusUpdateEvent; expect(workingEvent3.kind).toBe('status-update'); expect(workingEvent3.status.state).toBe('working'); // Status update: text-content (final LLM response) const textContentEvent = events[8].result as TaskStatusUpdateEvent; expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'text-content', }); expect(textContentEvent.status.message?.parts).toMatchObject([ { text: 'Tool executed successfully.' }, ]); assertUniqueFinalEventIsLast(events); expect(events.length).toBe(10); }); it('should include traceId in status updates when available', async () => { const traceId = 'test-trace-id'; sendMessageStreamSpy.mockImplementation(async function* () { yield* [ { type: 'content', value: 'Hello', traceId }, { type: 'thought', value: { subject: 'Thinking...' }, traceId }, ]; }); const agent = request.agent(app); const res = await agent .post('/') .send(createStreamMessageRequest('hello', 'a2a-trace-id-test')) .set('Content-Type', 'application/json') .expect(200); const events = streamToSSEEvents(res.text); // The first two events are task-creation and working status const textContentEvent = events[2].result as TaskStatusUpdateEvent; expect(textContentEvent.kind).toBe('status-update'); expect(textContentEvent.metadata?.['traceId']).toBe(traceId); const thoughtEvent = events[3].result as TaskStatusUpdateEvent; expect(thoughtEvent.kind).toBe('status-update'); expect(thoughtEvent.metadata?.['traceId']).toBe(traceId); }); describe('/listCommands', () => { it('should return a list of top-level commands', async () => { const mockCommands = [ { name: 'test-command', description: 'A test command', topLevel: true, arguments: [{ name: 'arg1', description: 'Argument 1' }], subCommands: [ { name: 'sub-command', description: 'A sub command', topLevel: false, execute: vi.fn(), }, ], execute: vi.fn(), }, { name: 'another-command', description: 'Another test command', topLevel: true, execute: vi.fn(), }, { name: 'not-top-level', description: 'Not a top level command', topLevel: false, execute: vi.fn(), }, ]; const getAllCommandsSpy = vi .spyOn(commandRegistry, 'getAllCommands') .mockReturnValue(mockCommands); const agent = request.agent(app); const res = await agent.get('/listCommands').expect(200); expect(res.body).toEqual({ commands: [ { name: 'test-command', description: 'A test command', arguments: [{ name: 'arg1', description: 'Argument 1' }], subCommands: [ { name: 'sub-command', description: 'A sub command', arguments: [], subCommands: [], }, ], }, { name: 'another-command', description: 'Another test command', arguments: [], subCommands: [], }, ], }); expect(getAllCommandsSpy).toHaveBeenCalledOnce(); getAllCommandsSpy.mockRestore(); }); it('should handle cyclic commands gracefully', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const cyclicCommand: Command = { name: 'cyclic-command', description: 'A cyclic command', topLevel: true, execute: vi.fn(), subCommands: [], }; cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle const getAllCommandsSpy = vi .spyOn(commandRegistry, 'getAllCommands') .mockReturnValue([cyclicCommand]); const agent = request.agent(app); const res = await agent.get('/listCommands').expect(200); expect(res.body.commands[0].name).toBe('cyclic-command'); expect(res.body.commands[0].subCommands).toEqual([]); expect(warnSpy).toHaveBeenCalledWith( 'Command cyclic-command already inserted in the response, skipping', ); getAllCommandsSpy.mockRestore(); warnSpy.mockRestore(); }); }); describe('/executeCommand', () => { const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }]; beforeEach(() => { getExtensionsSpy.mockReturnValue(mockExtensions); }); afterEach(() => { getExtensionsSpy.mockClear(); }); it('should return extensions for valid command', async () => { const mockExtensionsCommand = { name: 'extensions list', description: 'a mock command', execute: vi.fn(async (context: CommandContext) => { // Simulate the actual command's behavior const extensions = context.config.getExtensions(); return { name: 'extensions list', data: extensions }; }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand); const agent = request.agent(app); const res = await agent .post('/executeCommand') .send({ command: 'extensions list', args: [] }) .set('Content-Type', 'application/json') .expect(200); expect(res.body).toEqual({ name: 'extensions list', data: mockExtensions, }); expect(getExtensionsSpy).toHaveBeenCalled(); }); it('should return 404 for invalid command', async () => { vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined); const agent = request.agent(app); const res = await agent .post('/executeCommand') .send({ command: 'invalid command' }) .set('Content-Type', 'application/json') .expect(404); expect(res.body.error).toBe('Command not found: invalid command'); expect(getExtensionsSpy).not.toHaveBeenCalled(); }); it('should return 400 for missing command', async () => { const agent = request.agent(app); await agent .post('/executeCommand') .send({ args: [] }) .set('Content-Type', 'application/json') .expect(400); expect(getExtensionsSpy).not.toHaveBeenCalled(); }); it('should return 400 if args is not an array', async () => { const agent = request.agent(app); const res = await agent .post('/executeCommand') .send({ command: 'extensions.list', args: 'not-an-array' }) .set('Content-Type', 'application/json') .expect(400); expect(res.body.error).toBe('"args" field must be an array.'); expect(getExtensionsSpy).not.toHaveBeenCalled(); }); it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { const mockCommand = { name: 'test-command', description: 'a mock command', execute: vi .fn() .mockResolvedValue({ name: 'test-command', data: 'success' }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); delete process.env['CODER_AGENT_WORKSPACE_PATH']; const response = await request(app) .post('/executeCommand') .send({ command: 'test-command', args: [] }); expect(response.status).toBe(200); expect(response.body.data).toBe('success'); }); it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { const mockWorkspaceCommand = { name: 'workspace-command', description: 'A command that requires a workspace', requiresWorkspace: true, execute: vi .fn() .mockResolvedValue({ name: 'workspace-command', data: 'success' }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); delete process.env['CODER_AGENT_WORKSPACE_PATH']; const response = await request(app) .post('/executeCommand') .send({ command: 'workspace-command', args: [] }); expect(response.status).toBe(400); expect(response.body.error).toBe( 'Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.', ); }); it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => { const mockWorkspaceCommand = { name: 'workspace-command', description: 'A command that requires a workspace', requiresWorkspace: true, execute: vi .fn() .mockResolvedValue({ name: 'workspace-command', data: 'success' }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); process.env['CODER_AGENT_WORKSPACE_PATH'] = '/tmp/test-workspace'; const response = await request(app) .post('/executeCommand') .send({ command: 'workspace-command', args: [] }); expect(response.status).toBe(200); expect(response.body.data).toBe('success'); }); it('should include agentExecutor in context', async () => { const mockCommand = { name: 'context-check-command', description: 'checks context', execute: vi.fn(async (context: CommandContext) => { if (!context.agentExecutor) { throw new Error('agentExecutor missing'); } return { name: 'context-check-command', data: 'success' }; }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); const agent = request.agent(app); const res = await agent .post('/executeCommand') .send({ command: 'context-check-command', args: [] }) .set('Content-Type', 'application/json') .expect(200); expect(res.body.data).toBe('success'); }); describe('/executeCommand streaming', () => { it('should execute a streaming command and stream back events', (done: ( err?: unknown, ) => void) => { const executeSpy = vi.fn(async (context: CommandContext) => { context.eventBus?.publish({ kind: 'status-update', status: { state: 'working' }, taskId: 'test-task', contextId: 'test-context', final: false, }); context.eventBus?.publish({ kind: 'status-update', status: { state: 'completed' }, taskId: 'test-task', contextId: 'test-context', final: true, }); return { name: 'stream-test', data: 'done' }; }); const mockStreamCommand = { name: 'stream-test', description: 'A test streaming command', streaming: true, execute: executeSpy, }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); const agent = request.agent(app); agent .post('/executeCommand') .send({ command: 'stream-test', args: [] }) .set('Content-Type', 'application/json') .set('Accept', 'text/event-stream') .on('response', (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); res.on('end', () => { try { const events = streamToSSEEvents(data); expect(events.length).toBe(2); expect(events[0].result).toEqual({ kind: 'status-update', status: { state: 'working' }, taskId: 'test-task', contextId: 'test-context', final: false, }); expect(events[1].result).toEqual({ kind: 'status-update', status: { state: 'completed' }, taskId: 'test-task', contextId: 'test-context', final: true, }); expect(executeSpy).toHaveBeenCalled(); done(); } catch (e) { done(e); } }); }) .end(); }); it('should handle non-streaming commands gracefully', async () => { const mockNonStreamCommand = { name: 'non-stream-test', description: 'A test non-streaming command', execute: vi .fn() .mockResolvedValue({ name: 'non-stream-test', data: 'done' }), }; vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand); const agent = request.agent(app); const res = await agent .post('/executeCommand') .send({ command: 'non-stream-test', args: [] }) .set('Content-Type', 'application/json') .expect(200); expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' }); }); }); }); describe('main', () => { it('should listen on localhost only', async () => { const listenSpy = vi .spyOn(express.application, 'listen') .mockImplementation((...args: unknown[]) => { // Trigger the callback passed to listen const callback = args.find( (arg): arg is () => void => typeof arg === 'function', ); if (callback) { callback(); } return { address: () => ({ port: 1234 }), on: vi.fn(), once: vi.fn(), emit: vi.fn(), } as unknown as Server; }); // Avoid process.exit if possible, or mock it if main might fail const exitSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); await main(); expect(listenSpy).toHaveBeenCalledWith( expect.any(Number), 'localhost', expect.any(Function), ); listenSpy.mockRestore(); exitSpy.mockRestore(); }); }); }); ================================================ FILE: packages/a2a-server/src/http/app.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import express, { type Request } from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; import { type TaskStore, DefaultRequestHandler, InMemoryTaskStore, DefaultExecutionEventBus, type AgentExecutionEvent, UnauthenticatedUser, } from '@a2a-js/sdk/server'; import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import type { AgentSettings } from '../types.js'; import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js'; import { CoderAgentExecutor } from '../agent/executor.js'; import { requestStorage } from './requestStorage.js'; import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { commandRegistry } from '../commands/command-registry.js'; import { debugLogger, SimpleExtensionLoader, GitService, } from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; type CommandResponse = { name: string; description: string; arguments: CommandArgument[]; subCommands: CommandResponse[]; }; const coderAgentCard: AgentCard = { name: 'Gemini SDLC Agent', description: 'An agent that generates code based on natural language instructions and streams file outputs.', url: 'http://localhost:41242/', provider: { organization: 'Google', url: 'https://google.com', }, protocolVersion: '0.3.0', version: '0.0.2', // Incremented version capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: true, }, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', }, basicAuth: { type: 'http', scheme: 'basic', }, }, security: [{ bearerAuth: [] }, { basicAuth: [] }], defaultInputModes: ['text'], defaultOutputModes: ['text'], skills: [ { id: 'code_generation', name: 'Code Generation', description: 'Generates code snippets or complete files based on user requests, streaming the results.', tags: ['code', 'development', 'programming'], examples: [ 'Write a python function to calculate fibonacci numbers.', 'Create an HTML file with a basic button that alerts "Hello!" when clicked.', ], inputModes: ['text'], outputModes: ['text'], }, ], supportsAuthenticatedExtendedCard: false, }; export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } const customUserBuilder: UserBuilder = async (req: Request) => { const auth = req.headers['authorization']; if (auth) { const scheme = auth.split(' ')[0]; logger.info( `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, ); } if (!auth) return new UnauthenticatedUser(); // 1. Bearer Auth if (auth.startsWith('Bearer ')) { const token = auth.substring(7); if (token === 'valid-token') { return { userName: 'bearer-user', isAuthenticated: true }; } } // 2. Basic Auth if (auth.startsWith('Basic ')) { const credentials = Buffer.from(auth.substring(6), 'base64').toString(); if (credentials === 'admin:password') { return { userName: 'basic-user', isAuthenticated: true }; } } return new UnauthenticatedUser(); }; async function handleExecuteCommand( req: express.Request, res: express.Response, context: { config: Awaited>; git: GitService | undefined; agentExecutor: CoderAgentExecutor; }, ) { logger.info('[CoreAgent] Received /executeCommand request: ', req.body); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { command, args } = req.body; try { if (typeof command !== 'string') { return res.status(400).json({ error: 'Invalid "command" field.' }); } if (args && !Array.isArray(args)) { return res.status(400).json({ error: '"args" field must be an array.' }); } const commandToExecute = commandRegistry.get(command); if (commandToExecute?.requiresWorkspace) { if (!process.env['CODER_AGENT_WORKSPACE_PATH']) { return res.status(400).json({ error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`, }); } } if (!commandToExecute) { return res.status(404).json({ error: `Command not found: ${command}` }); } if (commandToExecute.streaming) { const eventBus = new DefaultExecutionEventBus(); res.setHeader('Content-Type', 'text/event-stream'); const eventHandler = (event: AgentExecutionEvent) => { const jsonRpcResponse = { jsonrpc: '2.0', // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion id: 'taskId' in event ? event.taskId : (event as Message).messageId, result: event, }; res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`); }; eventBus.on('event', eventHandler); await commandToExecute.execute({ ...context, eventBus }, args ?? []); eventBus.off('event', eventHandler); eventBus.finished(); return res.end(); // Explicit return for streaming path } else { const result = await commandToExecute.execute(context, args ?? []); logger.info('[CoreAgent] Sending /executeCommand response: ', result); return res.status(200).json(result); } } catch (e) { logger.error( `Error executing /executeCommand: ${command} with args: ${JSON.stringify( args, )}`, e, ); const errorMessage = e instanceof Error ? e.message : 'Unknown error executing command'; return res.status(500).json({ error: errorMessage }); } } export async function createApp() { try { // Load the server configuration once on startup. const workspaceRoot = setTargetDir(undefined); loadEnvironment(); const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); const config = await loadConfig( settings, new SimpleExtensionLoader(extensions), 'a2a-server', ); let git: GitService | undefined; if (config.getCheckpointingEnabled()) { git = new GitService(config.getTargetDir(), config.storage); await git.initialize(); } // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; let taskStoreForExecutor: TaskStore; let taskStoreForHandler: TaskStore; if (bucketName) { logger.info(`Using GCSTaskStore with bucket: ${bucketName}`); const gcsTaskStore = new GCSTaskStore(bucketName); taskStoreForExecutor = gcsTaskStore; taskStoreForHandler = new NoOpTaskStore(gcsTaskStore); } else { logger.info('Using InMemoryTaskStore'); const inMemoryTaskStore = new InMemoryTaskStore(); taskStoreForExecutor = inMemoryTaskStore; taskStoreForHandler = inMemoryTaskStore; } const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor); const context = { config, git, agentExecutor }; const requestHandler = new DefaultRequestHandler( coderAgentCard, taskStoreForHandler, agentExecutor, ); let expressApp = express(); expressApp.use((req, res, next) => { requestStorage.run({ req }, next); }); const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json()); expressApp.post('/tasks', async (req, res) => { try { const taskId = uuidv4(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const agentSettings = req.body.agentSettings as | AgentSettings | undefined; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const contextId = req.body.contextId || uuidv4(); const wrapper = await agentExecutor.createTask( taskId, contextId, agentSettings, ); await taskStoreForExecutor.save(wrapper.toSDKTask()); res.status(201).json(wrapper.id); } catch (error) { logger.error('[CoreAgent] Error creating task:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error creating task'; res.status(500).send({ error: errorMessage }); } }); expressApp.post('/executeCommand', (req, res) => { void handleExecuteCommand(req, res, context); }); expressApp.get('/listCommands', (req, res) => { try { const transformCommand = ( command: Command, visited: string[], ): CommandResponse | undefined => { const commandName = command.name; if (visited.includes(commandName)) { debugLogger.warn( `Command ${commandName} already inserted in the response, skipping`, ); return undefined; } return { name: command.name, description: command.description, arguments: command.arguments ?? [], subCommands: (command.subCommands ?? []) .map((subCommand) => transformCommand(subCommand, visited.concat(commandName)), ) .filter( (subCommand): subCommand is CommandResponse => !!subCommand, ), }; }; const commands = commandRegistry .getAllCommands() .filter((command) => command.topLevel) .map((command) => transformCommand(command, [])); return res.status(200).json({ commands }); } catch (e) { logger.error('Error executing /listCommands:', e); const errorMessage = e instanceof Error ? e.message : 'Unknown error listing commands'; return res.status(500).json({ error: errorMessage }); } }); expressApp.get('/tasks/metadata', async (req, res) => { // This endpoint is only meaningful if the task store is in-memory. if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) { res.status(501).send({ error: 'Listing all task metadata is only supported when using InMemoryTaskStore.', }); } try { const wrappers = agentExecutor.getAllTasks(); if (wrappers && wrappers.length > 0) { const tasksMetadata = await Promise.all( wrappers.map((wrapper) => wrapper.task.getMetadata()), ); res.status(200).json(tasksMetadata); } else { res.status(204).send(); } } catch (error) { logger.error('[CoreAgent] Error getting all task metadata:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error getting task metadata'; res.status(500).send({ error: errorMessage }); } }); expressApp.get('/tasks/:taskId/metadata', async (req, res) => { const taskId = req.params.taskId; let wrapper = agentExecutor.getTask(taskId); if (!wrapper) { const sdkTask = await taskStoreForExecutor.load(taskId); if (sdkTask) { wrapper = await agentExecutor.reconstruct(sdkTask); } } if (!wrapper) { res.status(404).send({ error: 'Task not found' }); return; } res.json({ metadata: await wrapper.task.getMetadata() }); }); return expressApp; } catch (error) { logger.error('[CoreAgent] Error during startup:', error); process.exit(1); } } export async function main() { try { const expressApp = await createApp(); const port = Number(process.env['CODER_AGENT_PORT'] || 0); const server = expressApp.listen(port, 'localhost', () => { const address = server.address(); let actualPort; if (process.env['CODER_AGENT_PORT']) { actualPort = process.env['CODER_AGENT_PORT']; } else if (address && typeof address !== 'string') { actualPort = address.port; } else { throw new Error('[Core Agent] Could not find port number.'); } updateCoderAgentCardUrl(Number(actualPort)); logger.info( `[CoreAgent] Agent Server started on http://localhost:${actualPort}`, ); logger.info( `[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`, ); logger.info('[CoreAgent] Press Ctrl+C to stop the server'); }); } catch (error) { logger.error('[CoreAgent] Error during startup:', error); process.exit(1); } } ================================================ FILE: packages/a2a-server/src/http/endpoints.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import type express from 'express'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { createApp, updateCoderAgentCardUrl } from './app.js'; import type { TaskMetadata } from '../types.js'; import { createMockConfig } from '../utils/testing_utils.js'; import { debugLogger, type Config } from '@google/gemini-cli-core'; // Mock the logger to avoid polluting test output // Comment out to help debug vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); // Mock Task.create to avoid its complex setup vi.mock('../agent/task.js', () => { class MockTask { id: string; contextId: string; taskState = 'submitted'; config = { getContentGeneratorConfig: vi .fn() .mockReturnValue({ model: 'gemini-pro' }), }; geminiClient = { initialize: vi.fn().mockResolvedValue(undefined), }; constructor(id: string, contextId: string) { this.id = id; this.contextId = contextId; } static create = vi .fn() .mockImplementation((id, contextId) => Promise.resolve(new MockTask(id, contextId)), ); getMetadata = vi.fn().mockImplementation(async () => ({ id: this.id, contextId: this.contextId, taskState: this.taskState, model: 'gemini-pro', mcpServers: [], availableTools: [], })); } return { Task: MockTask }; }); vi.mock('../config/config.js', async () => { const actual = await vi.importActual('../config/config.js'); return { ...actual, loadConfig: vi .fn() .mockImplementation(async () => createMockConfig({}) as Config), }; }); describe('Agent Server Endpoints', () => { let app: express.Express; let server: Server; let testWorkspace: string; const createTask = (contextId: string) => request(app) .post('/tasks') .send({ contextId, agentSettings: { kind: 'agent-settings', workspacePath: testWorkspace, }, }) .set('Content-Type', 'application/json'); beforeAll(async () => { // Create a unique temporary directory for the workspace to avoid conflicts testWorkspace = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-agent-test-'), ); app = await createApp(); await new Promise((resolve) => { server = app.listen(0, () => { const port = (server.address() as AddressInfo).port; updateCoderAgentCardUrl(port); resolve(); }); }); }); afterAll(async () => { if (server) { await new Promise((resolve, reject) => { server.close((err) => { if (err) return reject(err); resolve(); }); }); } if (testWorkspace) { try { fs.rmSync(testWorkspace, { recursive: true, force: true }); } catch (e) { debugLogger.warn(`Could not remove temp dir '${testWorkspace}':`, e); } } }); it('should create a new task via POST /tasks', async () => { const response = await createTask('test-context'); expect(response.status).toBe(201); expect(response.body).toBeTypeOf('string'); // Should return the task ID }, 7000); it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => { const createResponse = await createTask('test-context-2'); const taskId = createResponse.body; const response = await request(app).get(`/tasks/${taskId}/metadata`); expect(response.status).toBe(200); expect(response.body.metadata.id).toBe(taskId); }, 6000); it('should get metadata for all tasks via GET /tasks/metadata', async () => { const createResponse = await createTask('test-context-3'); const taskId = createResponse.body; const response = await request(app).get('/tasks/metadata'); expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); const taskMetadata = response.body.find( (m: TaskMetadata) => m.id === taskId, ); expect(taskMetadata).toBeDefined(); }); it('should return 404 for a non-existent task', async () => { const response = await request(app).get('/tasks/fake-task/metadata'); expect(response.status).toBe(404); }); it('should return agent metadata via GET /.well-known/agent-card.json', async () => { const response = await request(app).get('/.well-known/agent-card.json'); const port = (server.address() as AddressInfo).port; expect(response.status).toBe(200); expect(response.body.name).toBe('Gemini SDLC Agent'); expect(response.body.url).toBe(`http://localhost:${port}/`); }); }); ================================================ FILE: packages/a2a-server/src/http/requestStorage.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type express from 'express'; import { AsyncLocalStorage } from 'node:async_hooks'; export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>(); ================================================ FILE: packages/a2a-server/src/http/server.ts ================================================ #!/usr/bin/env -S node --no-warnings=DEP0040 /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as url from 'node:url'; import * as path from 'node:path'; import { logger } from '../utils/logger.js'; import { main } from './app.js'; // Check if the module is the main script being run const isMainModule = path.basename(process.argv[1]) === path.basename(url.fileURLToPath(import.meta.url)); if ( import.meta.url.startsWith('file:') && isMainModule && process.env['NODE_ENV'] !== 'test' ) { process.on('uncaughtException', (error) => { logger.error('Unhandled exception:', error); process.exit(1); }); main().catch((error) => { logger.error('[CoreAgent] Unhandled error in main:', error); process.exit(1); }); } ================================================ FILE: packages/a2a-server/src/index.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export * from './agent/executor.js'; export * from './http/app.js'; export * from './types.js'; ================================================ FILE: packages/a2a-server/src/persistence/gcs.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Storage } from '@google-cloud/storage'; import * as fse from 'fs-extra'; import * as tar from 'tar'; import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; import { describe, it, expect, beforeEach, vi, type Mocked, type MockedClass, type Mock, } from 'vitest'; import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; import { logger } from '../utils/logger.js'; import * as configModule from '../config/config.js'; import { getPersistedState, METADATA_KEY } from '../types.js'; // Mock dependencies const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), createReadStream: vi.fn(), })); vi.mock('@google-cloud/storage'); vi.mock('fs-extra', () => ({ pathExists: vi.fn(), readdir: vi.fn(), remove: vi.fn(), ensureDir: vi.fn(), createReadStream: vi.fn(), })); vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, promises: { ...actual.promises, readdir: fsMocks.readdir, }, createReadStream: fsMocks.createReadStream, }; }); vi.mock('fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, promises: { ...actual.promises, readdir: fsMocks.readdir, }, createReadStream: fsMocks.createReadStream, }; }); vi.mock('tar', async () => { const actualFs = await vi.importActual('node:fs'); return { c: vi.fn(({ file }) => { if (file) { actualFs.writeFileSync(file, Buffer.from('dummy tar content')); } return Promise.resolve(); }), x: vi.fn().mockResolvedValue(undefined), t: vi.fn().mockResolvedValue(undefined), r: vi.fn().mockResolvedValue(undefined), u: vi.fn().mockResolvedValue(undefined), }; }); vi.mock('zlib'); vi.mock('uuid'); vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); vi.mock('../config/config.js', () => ({ setTargetDir: vi.fn(), })); vi.mock('node:stream/promises', () => ({ pipeline: vi.fn(), })); vi.mock('../types.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getPersistedState: vi.fn(), }; }); const mockStorage = Storage as MockedClass; const mockFse = fse as Mocked; const mockCreateReadStream = fsMocks.createReadStream; const mockTar = tar as Mocked; const mockGzipSync = gzipSync as Mock; const mockGunzipSync = gunzipSync as Mock; const mockUuidv4 = uuidv4 as Mock; const mockSetTargetDir = configModule.setTargetDir as Mock; const mockGetPersistedState = getPersistedState as Mock; const TEST_METADATA_KEY = METADATA_KEY || '__persistedState'; type MockWriteStream = { emit: Mock<(event: string, ...args: unknown[]) => boolean>; removeListener: Mock< (event: string, cb: (error?: Error | null) => void) => MockWriteStream >; once: Mock< (event: string, cb: (error?: Error | null) => void) => MockWriteStream >; on: Mock< (event: string, cb: (error?: Error | null) => void) => MockWriteStream >; destroy: Mock<() => void>; write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>; end: Mock<(cb?: unknown) => void>; destroyed: boolean; }; type MockFile = { save: Mock<(data: Buffer | string) => Promise>; download: Mock<() => Promise<[Buffer]>>; exists: Mock<() => Promise<[boolean]>>; createWriteStream: Mock<() => MockWriteStream>; }; type MockBucket = { exists: Mock<() => Promise<[boolean]>>; file: Mock<(path: string) => MockFile>; name: string; }; type MockStorageInstance = { bucket: Mock<(name: string) => MockBucket>; getBuckets: Mock<() => Promise<[Array<{ name: string }>]>>; createBucket: Mock<(name: string) => Promise<[MockBucket]>>; }; describe('GCSTaskStore', () => { let bucketName: string; let mockBucket: MockBucket; let mockFile: MockFile; let mockWriteStream: MockWriteStream; let mockStorageInstance: MockStorageInstance; beforeEach(() => { vi.clearAllMocks(); bucketName = 'test-bucket'; mockWriteStream = { emit: vi.fn().mockReturnValue(true), removeListener: vi.fn().mockReturnValue(mockWriteStream), on: vi.fn((event, cb) => { if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream; }), once: vi.fn((event, cb) => { if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream; }), destroy: vi.fn(), write: vi.fn().mockReturnValue(true), end: vi.fn(), destroyed: false, }; mockFile = { save: vi.fn().mockResolvedValue(undefined), download: vi.fn().mockResolvedValue([Buffer.from('')]), exists: vi.fn().mockResolvedValue([true]), createWriteStream: vi.fn().mockReturnValue(mockWriteStream), }; mockBucket = { exists: vi.fn().mockResolvedValue([true]), file: vi.fn().mockReturnValue(mockFile), name: bucketName, }; mockStorageInstance = { bucket: vi.fn().mockReturnValue(mockBucket), getBuckets: vi.fn().mockResolvedValue([[{ name: bucketName }]]), createBucket: vi.fn().mockResolvedValue([mockBucket]), }; mockStorage.mockReturnValue(mockStorageInstance as unknown as Storage); mockUuidv4.mockReturnValue('test-uuid'); mockSetTargetDir.mockReturnValue('/tmp/workdir'); mockGetPersistedState.mockReturnValue({ _agentSettings: {}, _taskState: 'submitted', }); (fse.pathExists as Mock).mockResolvedValue(true); fsMocks.readdir.mockResolvedValue(['file1.txt']); mockFse.remove.mockResolvedValue(undefined); mockFse.ensureDir.mockResolvedValue(undefined); mockGzipSync.mockReturnValue(Buffer.from('compressed')); mockGunzipSync.mockReturnValue(Buffer.from('{}')); mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() }); mockFse.createReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn(), } as unknown as import('node:fs').ReadStream); }); describe('Constructor & Initialization', () => { it('should initialize and check bucket existence', async () => { const store = new GCSTaskStore(bucketName); await store['ensureBucketInitialized'](); expect(mockStorage).toHaveBeenCalledTimes(1); expect(mockStorageInstance.getBuckets).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('Bucket test-bucket exists'), ); }); it('should create bucket if it does not exist', async () => { mockStorageInstance.getBuckets.mockResolvedValue([[]]); const store = new GCSTaskStore(bucketName); await store['ensureBucketInitialized'](); expect(mockStorageInstance.createBucket).toHaveBeenCalledWith(bucketName); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('Bucket test-bucket created successfully'), ); }); it('should throw if bucket creation fails', async () => { mockStorageInstance.getBuckets.mockResolvedValue([[]]); mockStorageInstance.createBucket.mockRejectedValue( new Error('Create failed'), ); const store = new GCSTaskStore(bucketName); await expect(store['ensureBucketInitialized']()).rejects.toThrow( 'Failed to create GCS bucket test-bucket: Error: Create failed', ); }); }); describe('save', () => { const mockTask: SDKTask = { id: 'task1', contextId: 'ctx1', kind: 'task', status: { state: 'working' }, metadata: {}, }; it('should save metadata and workspace', async () => { const store = new GCSTaskStore(bucketName); await store.save(mockTask); expect(mockFile.save).toHaveBeenCalledTimes(1); expect(mockTar.c).toHaveBeenCalledTimes(1); expect(mockFse.remove).toHaveBeenCalledTimes(1); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('metadata saved to GCS'), ); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('workspace saved to GCS'), ); }); it('should handle tar creation failure', async () => { mockFse.pathExists.mockImplementation( async (path) => !path.toString().includes('task-task1-workspace-test-uuid.tar.gz'), ); const store = new GCSTaskStore(bucketName); await expect(store.save(mockTask)).rejects.toThrow( 'tar.c command failed to create', ); }); it('should throw an error if taskId contains path traversal sequences', async () => { const store = new GCSTaskStore('test-bucket'); const maliciousTask: SDKTask = { id: '../../../malicious-task', metadata: { _internal: { agentSettings: { cacheDir: '/tmp/cache', dataDir: '/tmp/data', logDir: '/tmp/logs', tempDir: '/tmp/temp', }, taskState: 'working', }, }, kind: 'task', status: { state: 'working', timestamp: new Date().toISOString(), }, contextId: 'test-context', history: [], artifacts: [], }; await expect(store.save(maliciousTask)).rejects.toThrow( 'Invalid taskId: ../../../malicious-task', ); }); }); describe('load', () => { it('should load task metadata and workspace', async () => { mockGunzipSync.mockReturnValue( Buffer.from( JSON.stringify({ [TEST_METADATA_KEY]: { _agentSettings: {}, _taskState: 'submitted', }, _contextId: 'ctx1', }), ), ); mockFile.download.mockResolvedValue([Buffer.from('compressed metadata')]); mockFile.download.mockResolvedValueOnce([ Buffer.from('compressed metadata'), ]); mockBucket.file = vi.fn((path) => { const newMockFile = { ...mockFile }; if (path.includes('metadata')) { newMockFile.download = vi .fn() .mockResolvedValue([Buffer.from('compressed metadata')]); newMockFile.exists = vi.fn().mockResolvedValue([true]); } else { newMockFile.download = vi .fn() .mockResolvedValue([Buffer.from('compressed workspace')]); newMockFile.exists = vi.fn().mockResolvedValue([true]); } return newMockFile; }); const store = new GCSTaskStore(bucketName); const task = await store.load('task1'); expect(task).toBeDefined(); expect(task?.id).toBe('task1'); expect(mockBucket.file).toHaveBeenCalledWith( 'tasks/task1/metadata.tar.gz', ); expect(mockBucket.file).toHaveBeenCalledWith( 'tasks/task1/workspace.tar.gz', ); expect(mockTar.x).toHaveBeenCalledTimes(1); expect(mockFse.remove).toHaveBeenCalledTimes(1); }); it('should return undefined if metadata not found', async () => { mockFile.exists.mockResolvedValue([false]); const store = new GCSTaskStore(bucketName); const task = await store.load('task1'); expect(task).toBeUndefined(); expect(mockBucket.file).toHaveBeenCalledWith( 'tasks/task1/metadata.tar.gz', ); }); it('should load metadata even if workspace not found', async () => { mockGunzipSync.mockReturnValue( Buffer.from( JSON.stringify({ [TEST_METADATA_KEY]: { _agentSettings: {}, _taskState: 'submitted', }, _contextId: 'ctx1', }), ), ); mockBucket.file = vi.fn((path) => { const newMockFile = { ...mockFile }; if (path.includes('workspace.tar.gz')) { newMockFile.exists = vi.fn().mockResolvedValue([false]); } else { newMockFile.exists = vi.fn().mockResolvedValue([true]); newMockFile.download = vi .fn() .mockResolvedValue([Buffer.from('compressed metadata')]); } return newMockFile; }); const store = new GCSTaskStore(bucketName); const task = await store.load('task1'); expect(task).toBeDefined(); expect(mockTar.x).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining('workspace archive not found'), ); }); }); it('should throw an error if taskId contains path traversal sequences', async () => { const store = new GCSTaskStore('test-bucket'); const maliciousTaskId = '../../../malicious-task'; await expect(store.load(maliciousTaskId)).rejects.toThrow( `Invalid taskId: ${maliciousTaskId}`, ); }); }); describe('NoOpTaskStore', () => { let realStore: TaskStore; let noOpStore: NoOpTaskStore; beforeEach(() => { // Create a mock of the real store to delegate to realStore = { save: vi.fn(), load: vi.fn().mockResolvedValue({ id: 'task-123' } as SDKTask), }; noOpStore = new NoOpTaskStore(realStore); }); it("should not call the real store's save method", async () => { const mockTask: SDKTask = { id: 'test-task' } as SDKTask; await noOpStore.save(mockTask); expect(realStore.save).not.toHaveBeenCalled(); }); it('should delegate the load method to the real store', async () => { const taskId = 'task-123'; const result = await noOpStore.load(taskId); expect(realStore.load).toHaveBeenCalledWith(taskId); expect(result).toBeDefined(); expect(result?.id).toBe(taskId); }); }); ================================================ FILE: packages/a2a-server/src/persistence/gcs.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Storage } from '@google-cloud/storage'; import { gzipSync, gunzipSync } from 'node:zlib'; import * as tar from 'tar'; import * as fse from 'fs-extra'; import { promises as fsPromises, createReadStream } from 'node:fs'; import { tmpdir } from '@google/gemini-cli-core'; import { join } from 'node:path'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; import { logger } from '../utils/logger.js'; import { setTargetDir } from '../config/config.js'; import { getPersistedState, type PersistedTaskMetadata } from '../types.js'; import { v4 as uuidv4 } from 'uuid'; type ObjectType = 'metadata' | 'workspace'; const getTmpArchiveFilename = (taskId: string): string => `task-${taskId}-workspace-${uuidv4()}.tar.gz`; // Validate the taskId to prevent path traversal attacks by ensuring it only contains safe characters. const isTaskIdValid = (taskId: string): boolean => { // Allow only alphanumeric characters, dashes, and underscores, and ensure it's not empty. const validTaskIdRegex = /^[a-zA-Z0-9_-]+$/; return validTaskIdRegex.test(taskId); }; export class GCSTaskStore implements TaskStore { private storage: Storage; private bucketName: string; private bucketInitialized: Promise; constructor(bucketName: string) { if (!bucketName) { throw new Error('GCS bucket name is required.'); } this.storage = new Storage(); this.bucketName = bucketName; logger.info(`GCSTaskStore initializing with bucket: ${this.bucketName}`); // Prerequisites: user account or service account must have storage admin IAM role // and the bucket name must be unique. this.bucketInitialized = this.initializeBucket(); } private async initializeBucket(): Promise { try { const [buckets] = await this.storage.getBuckets(); const exists = buckets.some((bucket) => bucket.name === this.bucketName); if (!exists) { logger.info( `Bucket ${this.bucketName} does not exist in the list. Attempting to create...`, ); try { await this.storage.createBucket(this.bucketName); logger.info(`Bucket ${this.bucketName} created successfully.`); } catch (createError) { logger.info( `Failed to create bucket ${this.bucketName}: ${createError}`, ); throw new Error( `Failed to create GCS bucket ${this.bucketName}: ${createError}`, ); } } else { logger.info(`Bucket ${this.bucketName} exists.`); } } catch (error) { logger.info( `Error during bucket initialization for ${this.bucketName}: ${error}`, ); throw new Error( `Failed to initialize GCS bucket ${this.bucketName}: ${error}`, ); } } private async ensureBucketInitialized(): Promise { await this.bucketInitialized; } private getObjectPath(taskId: string, type: ObjectType): string { if (!isTaskIdValid(taskId)) { throw new Error(`Invalid taskId: ${taskId}`); } return `tasks/${taskId}/${type}.tar.gz`; } async save(task: SDKTask): Promise { await this.ensureBucketInitialized(); const taskId = task.id; const persistedState = getPersistedState( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion task.metadata as PersistedTaskMetadata, ); if (!persistedState) { throw new Error(`Task ${taskId} is missing persisted state in metadata.`); } const workDir = process.cwd(); const metadataObjectPath = this.getObjectPath(taskId, 'metadata'); const workspaceObjectPath = this.getObjectPath(taskId, 'workspace'); const dataToStore = task.metadata; try { const jsonString = JSON.stringify(dataToStore); const compressedMetadata = gzipSync(Buffer.from(jsonString)); const metadataFile = this.storage .bucket(this.bucketName) .file(metadataObjectPath); await metadataFile.save(compressedMetadata, { contentType: 'application/gzip', }); logger.info( `Task ${taskId} metadata saved to GCS: gs://${this.bucketName}/${metadataObjectPath}`, ); if (await fse.pathExists(workDir)) { const entries = await fsPromises.readdir(workDir); if (entries.length > 0) { const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId)); try { await tar.c( { gzip: true, file: tmpArchiveFile, cwd: workDir, portable: true, }, entries, ); if (!(await fse.pathExists(tmpArchiveFile))) { throw new Error( `tar.c command failed to create ${tmpArchiveFile}`, ); } const workspaceFile = this.storage .bucket(this.bucketName) .file(workspaceObjectPath); const sourceStream = createReadStream(tmpArchiveFile); const destStream = workspaceFile.createWriteStream({ contentType: 'application/gzip', resumable: true, }); await new Promise((resolve, reject) => { sourceStream.on('error', (err) => { logger.error( `Error in source stream for ${tmpArchiveFile}:`, err, ); // Attempt to close destStream if source fails if (!destStream.destroyed) { destStream.destroy(err); } reject(err); }); destStream.on('error', (err) => { logger.error( `Error in GCS dest stream for ${workspaceObjectPath}:`, err, ); reject(err); }); destStream.on('finish', () => { logger.info( `GCS destStream finished for ${workspaceObjectPath}`, ); resolve(); }); logger.info( `Piping ${tmpArchiveFile} to GCS object ${workspaceObjectPath}`, ); sourceStream.pipe(destStream); }); logger.info( `Task ${taskId} workspace saved to GCS: gs://${this.bucketName}/${workspaceObjectPath}`, ); } catch (error) { logger.error( `Error during workspace save process for ${taskId}:`, error, ); throw error; } finally { logger.info(`Cleaning up temporary file: ${tmpArchiveFile}`); try { if (await fse.pathExists(tmpArchiveFile)) { await fse.remove(tmpArchiveFile); logger.info( `Successfully removed temporary file: ${tmpArchiveFile}`, ); } else { logger.warn( `Temporary file not found for cleanup: ${tmpArchiveFile}`, ); } } catch (removeError) { logger.error( `Error removing temporary file ${tmpArchiveFile}:`, removeError, ); } } } else { logger.info( `Workspace directory ${workDir} is empty, skipping workspace save for task ${taskId}.`, ); } } else { logger.info( `Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`, ); } } catch (error) { logger.error(`Failed to save task ${taskId} to GCS:`, error); throw error; } } async load(taskId: string): Promise { await this.ensureBucketInitialized(); const metadataObjectPath = this.getObjectPath(taskId, 'metadata'); const workspaceObjectPath = this.getObjectPath(taskId, 'workspace'); try { const metadataFile = this.storage .bucket(this.bucketName) .file(metadataObjectPath); const [metadataExists] = await metadataFile.exists(); if (!metadataExists) { logger.info(`Task ${taskId} metadata not found in GCS.`); return undefined; } const [compressedMetadata] = await metadataFile.download(); const jsonData = gunzipSync(compressedMetadata).toString(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const loadedMetadata = JSON.parse(jsonData); logger.info(`Task ${taskId} metadata loaded from GCS.`); const persistedState = getPersistedState(loadedMetadata); if (!persistedState) { throw new Error( `Loaded metadata for task ${taskId} is missing internal persisted state.`, ); } const agentSettings = persistedState._agentSettings; const workDir = setTargetDir(agentSettings); await fse.ensureDir(workDir); const workspaceFile = this.storage .bucket(this.bucketName) .file(workspaceObjectPath); const [workspaceExists] = await workspaceFile.exists(); if (workspaceExists) { const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId)); try { await workspaceFile.download({ destination: tmpArchiveFile }); await tar.x({ file: tmpArchiveFile, cwd: workDir }); logger.info( `Task ${taskId} workspace restored from GCS to ${workDir}`, ); } finally { if (await fse.pathExists(tmpArchiveFile)) { await fse.remove(tmpArchiveFile); } } } else { logger.info(`Task ${taskId} workspace archive not found in GCS.`); } return { id: taskId, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contextId: loadedMetadata._contextId || uuidv4(), kind: 'task', status: { state: persistedState._taskState, timestamp: new Date().toISOString(), }, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment metadata: loadedMetadata, history: [], artifacts: [], }; } catch (error) { logger.error(`Failed to load task ${taskId} from GCS:`, error); throw error; } } } export class NoOpTaskStore implements TaskStore { constructor(private realStore: TaskStore) {} async save(task: SDKTask): Promise { logger.info(`[NoOpTaskStore] save called for task ${task.id} - IGNORED`); return Promise.resolve(); } async load(taskId: string): Promise { logger.info( `[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`, ); return this.realStore.load(taskId); } } ================================================ FILE: packages/a2a-server/src/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { MCPServerStatus, ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import type { TaskState } from '@a2a-js/sdk'; // Interfaces and enums for the CoderAgent protocol. export enum CoderAgentEvent { /** * An event requesting one or more tool call confirmations. */ ToolCallConfirmationEvent = 'tool-call-confirmation', /** * An event updating on the status of one or more tool calls. */ ToolCallUpdateEvent = 'tool-call-update', /** * An event providing text updates on the task. */ TextContentEvent = 'text-content', /** * An event that indicates a change in the task's execution state. */ StateChangeEvent = 'state-change', /** * An user-sent event to initiate the agent. */ StateAgentSettingsEvent = 'agent-settings', /** * An event that contains a thought from the agent. */ ThoughtEvent = 'thought', /** * An event that contains citation from the agent. */ CitationEvent = 'citation', } export interface AgentSettings { kind: CoderAgentEvent.StateAgentSettingsEvent; workspacePath: string; autoExecute?: boolean; } export interface ToolCallConfirmation { kind: CoderAgentEvent.ToolCallConfirmationEvent; } export interface ToolCallUpdate { kind: CoderAgentEvent.ToolCallUpdateEvent; } export interface TextContent { kind: CoderAgentEvent.TextContentEvent; } export interface StateChange { kind: CoderAgentEvent.StateChangeEvent; } export interface Thought { kind: CoderAgentEvent.ThoughtEvent; } export interface Citation { kind: CoderAgentEvent.CitationEvent; } export type ThoughtSummary = { subject: string; description: string; }; export interface ToolConfirmationResponse { outcome: ToolConfirmationOutcome; callId: string; } export type CoderAgentMessage = | AgentSettings | ToolCallConfirmation | ToolCallUpdate | TextContent | StateChange | Thought | Citation; export interface TaskMetadata { id: string; contextId: string; taskState: TaskState; model: string; mcpServers: Array<{ name: string; status: MCPServerStatus; tools: Array<{ name: string; description: string; parameterSchema: unknown; }>; }>; availableTools: Array<{ name: string; description: string; parameterSchema: unknown; }>; } export interface PersistedStateMetadata { _agentSettings: AgentSettings; _taskState: TaskState; } export type PersistedTaskMetadata = { [k: string]: unknown }; export const METADATA_KEY = '__persistedState'; function isAgentSettings(value: unknown): value is AgentSettings { return ( typeof value === 'object' && value !== null && 'kind' in value && value.kind === CoderAgentEvent.StateAgentSettingsEvent && 'workspacePath' in value && typeof value.workspacePath === 'string' ); } function isPersistedStateMetadata( value: unknown, ): value is PersistedStateMetadata { return ( typeof value === 'object' && value !== null && '_agentSettings' in value && '_taskState' in value && isAgentSettings(value._agentSettings) ); } export function getPersistedState( metadata: PersistedTaskMetadata, ): PersistedStateMetadata | undefined { const state = metadata?.[METADATA_KEY]; if (isPersistedStateMetadata(state)) { return state; } return undefined; } export function getContextIdFromMetadata( metadata: PersistedTaskMetadata | undefined, ): string | undefined { if (!metadata) { return undefined; } const contextId = metadata['_contextId']; return typeof contextId === 'string' ? contextId : undefined; } export function getAgentSettingsFromMetadata( metadata: PersistedTaskMetadata | undefined, ): AgentSettings | undefined { if (!metadata) { return undefined; } const coderAgent = metadata['coderAgent']; if (isAgentSettings(coderAgent)) { return coderAgent; } return undefined; } export function setPersistedState( metadata: PersistedTaskMetadata, state: PersistedStateMetadata, ): PersistedTaskMetadata { return { ...metadata, [METADATA_KEY]: state, }; } ================================================ FILE: packages/a2a-server/src/utils/executor_utils.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Message } from '@a2a-js/sdk'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; import { CoderAgentEvent, type StateChange } from '../types.js'; export async function pushTaskStateFailed( error: unknown, eventBus: ExecutionEventBus, taskId: string, contextId: string, ) { const errorMessage = error instanceof Error ? error.message : 'Agent execution error'; const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; eventBus.publish({ kind: 'status-update', taskId, contextId, status: { state: 'failed', message: { kind: 'message', role: 'agent', parts: [ { kind: 'text', text: errorMessage, }, ], messageId: uuidv4(), taskId, contextId, } as Message, }, final: true, metadata: { coderAgent: stateChange, model: 'unknown', error: errorMessage, }, }); } ================================================ FILE: packages/a2a-server/src/utils/logger.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import winston from 'winston'; const logger = winston.createLogger({ level: 'info', format: winston.format.combine( // First, add a timestamp to the log info object winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS A', // Custom timestamp format }), // Here we define the custom output format winston.format.printf((info) => { const { level, timestamp, message, ...rest } = info; return ( `[${level.toUpperCase()}] ${timestamp} -- ${message}` + `${Object.keys(rest).length > 0 ? `\n${JSON.stringify(rest, null, 2)}` : ''}` ); // Only print ...rest if present }), ), transports: [new winston.transports.Console()], }); export { logger }; ================================================ FILE: packages/a2a-server/src/utils/testing_utils.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import type { Task as SDKTask, TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, } from '@a2a-js/sdk'; import { ApprovalMode, DEFAULT_GEMINI_MODEL, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, GeminiClient, HookSystem, type MessageBus, PolicyDecision, tmpdir, type Config, type Storage, NoopSandboxManager, type ToolRegistry, type SandboxManager, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { expect, vi } from 'vitest'; export function createMockConfig( overrides: Partial = {}, ): Partial { const tmpDir = tmpdir(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { get config() { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return this as unknown as Config; }, get toolRegistry() { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = this as unknown as Config; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return config.getToolRegistry?.() as unknown as ToolRegistry; }, get messageBus() { return ( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (this as unknown as Config).getMessageBus?.() as unknown as MessageBus ); }, get geminiClient() { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = this as unknown as Config; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return config.getGeminiClient?.() as unknown as GeminiClient; }, getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), getAllToolNames: vi.fn().mockReturnValue([]), getAllTools: vi.fn().mockReturnValue([]), getToolsByServer: vi.fn().mockReturnValue([]), }), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getIdeMode: vi.fn().mockReturnValue(false), isInteractive: () => true, getAllowedTools: vi.fn().mockReturnValue([]), getWorkspaceContext: vi.fn().mockReturnValue({ isPathWithinWorkspace: () => true, }), getTargetDir: () => tmpDir, getCheckpointingEnabled: vi.fn().mockReturnValue(false), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion storage: { getProjectTempDir: () => tmpDir, getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'), } as Storage, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), getDebugMode: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }), getModel: vi.fn().mockReturnValue('gemini-pro'), getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), setFallbackModelHandler: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), getProxy: vi.fn().mockReturnValue(undefined), getHistory: vi.fn().mockReturnValue([]), getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'), getSessionId: vi.fn().mockReturnValue('test-session-id'), getUserTier: vi.fn(), getMessageBus: vi.fn(), getPolicyEngine: vi.fn(), getEnableExtensionReloading: vi.fn().mockReturnValue(false), getEnableHooks: vi.fn().mockReturnValue(false), getMcpClientManager: vi.fn().mockReturnValue({ getMcpServers: vi.fn().mockReturnValue({}), }), getGitService: vi.fn(), validatePathAccess: vi.fn().mockReturnValue(undefined), getShellExecutionConfig: vi.fn().mockReturnValue({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion sandboxManager: new NoopSandboxManager() as unknown as SandboxManager, sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }, }), ...overrides, } as unknown as Config; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (mockConfig as unknown as { config: Config; promptId: string }).promptId = 'test-prompt-id'; mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); mockConfig.getHookSystem = vi .fn() .mockReturnValue(new HookSystem(mockConfig)); mockConfig.getGeminiClient = vi .fn() .mockReturnValue(new GeminiClient(mockConfig)); mockConfig.getPolicyEngine = vi.fn().mockReturnValue({ check: async () => { const mode = mockConfig.getApprovalMode(); if (mode === ApprovalMode.YOLO) { return { decision: PolicyDecision.ALLOW }; } return { decision: PolicyDecision.ASK_USER }; }, }); return mockConfig; } export function createStreamMessageRequest( text: string, messageId: string, taskId?: string, ) { const request: { jsonrpc: string; id: string; method: string; params: { message: { kind: string; role: string; parts: [{ kind: string; text: string }]; messageId: string; }; metadata: { coderAgent: { kind: string; workspacePath: string; }; }; taskId?: string; }; } = { jsonrpc: '2.0', id: '1', method: 'message/stream', params: { message: { kind: 'message', role: 'user', parts: [{ kind: 'text', text }], messageId, }, metadata: { coderAgent: { kind: 'agent-settings', workspacePath: '/tmp', }, }, }, }; if (taskId) { request.params.taskId = taskId; } return request; } export function assertUniqueFinalEventIsLast( events: SendStreamingMessageSuccessResponse[], ) { // Final event is input-required & final // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent; expect(finalEvent.metadata?.['coderAgent']).toMatchObject({ kind: 'state-change', }); expect(finalEvent.status?.state).toBe('input-required'); expect(finalEvent.final).toBe(true); // There is only one event with final and its the last expect( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length, ).toBe(1); expect( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final), ).toBe(events.length - 1); } export function assertTaskCreationAndWorkingStatus( events: SendStreamingMessageSuccessResponse[], ) { // Initial task creation event // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const taskEvent = events[0].result as SDKTask; expect(taskEvent.kind).toBe('task'); expect(taskEvent.status.state).toBe('submitted'); // Status update: working // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const workingEvent = events[1].result as TaskStatusUpdateEvent; expect(workingEvent.kind).toBe('status-update'); expect(workingEvent.status.state).toBe('working'); } ================================================ FILE: packages/a2a-server/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", "lib": ["DOM", "DOM.Iterable", "ES2023"], "composite": true, "types": ["node", "vitest/globals"] }, "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], "exclude": ["node_modules", "dist"] } ================================================ FILE: packages/a2a-server/vitest.config.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], exclude: ['**/node_modules/**', '**/dist/**'], globals: true, reporters: ['default', 'junit'], silent: true, outputFile: { junit: 'junit.xml', }, coverage: { enabled: true, provider: 'v8', reportsDirectory: './coverage', include: ['src/**/*'], reporter: [ ['text', { file: 'full-text-summary.txt' }], 'html', 'json', 'lcov', 'cobertura', ['json-summary', { outputFile: 'coverage-summary.json' }], ], }, poolOptions: { threads: { minThreads: 8, maxThreads: 16, }, }, server: { deps: { inline: [/@google\/gemini-cli-core/], }, }, }, }); ================================================ FILE: packages/cli/GEMINI.md ================================================ ## React & Ink (CLI UI) - **Side Effects**: Use reducers for complex state transitions; avoid `setState` triggers in callbacks. - Always fix react-hooks/exhaustive-deps lint errors by adding the missing dependencies. - **Shortcuts**: only define keyboard shortcuts in `packages/cli/src/ui/key/keyBindings.ts` - Do not implement any logic performing custom string measurement or string truncation. Use Ink layout instead leveraging ResizeObserver as needed. - Avoid prop drilling when at all possible. ## Testing - **Utilities**: Use `renderWithProviders` and `waitFor` from `packages/cli/src/test-utils/`. - **Snapshots**: Use `toMatchSnapshot()` to verify Ink output. - **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for UI components whenever colors or detailed visual layout matter. SVG snapshots capture styling accurately. Make sure to await the `waitUntilReady()` of the render result before asserting. After updating SVG snapshots, always examine the resulting `.svg` files (e.g. by reading their content or visually inspecting them) to ensure the render and colors actually look as expected and don't just contain an error message. - **Mocks**: Use mocks as sparingly as possible. ================================================ FILE: packages/cli/examples/ask-user-dialog-demo.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useState } from 'react'; import { render, Box, Text } from 'ink'; import { AskUserDialog } from '../src/ui/components/AskUserDialog.js'; import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; const DEMO_QUESTIONS: Question[] = [ { question: 'What type of project are you building?', header: 'Project Type', options: [ { label: 'Web Application', description: 'React, Next.js, or similar' }, { label: 'CLI Tool', description: 'Command-line interface with Node.js' }, { label: 'Library', description: 'NPM package or shared utility' }, ], multiSelect: false, }, { question: 'Which features should be enabled?', header: 'Features', options: [ { label: 'TypeScript', description: 'Add static typing' }, { label: 'ESLint', description: 'Add linting and formatting' }, { label: 'Unit Tests', description: 'Add Vitest setup' }, { label: 'CI/CD', description: 'Add GitHub Actions' }, ], multiSelect: true, }, { question: 'What is the project name?', header: 'Name', type: QuestionType.TEXT, placeholder: 'my-awesome-project', }, { question: 'Initialize git repository?', header: 'Git', type: QuestionType.YESNO, }, ]; const Demo = () => { const [result, setResult] = useState(null); const [cancelled, setCancelled] = useState(false); if (cancelled) { return ( Dialog was cancelled. Project initialization aborted. ); } if (result) { return ( Success! Project Configuration: {DEMO_QUESTIONS.map((q, i) => ( {q.header}: {result[i] || '(not answered)'} ))} Press Ctrl+C to exit ); } return ( AskUserDialog Demo setCancelled(true)} /> ); }; render(); ================================================ FILE: packages/cli/examples/scrollable-list-demo.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useState, useEffect, useRef } from 'react'; import { render, Box, Text, useInput, useStdout } from 'ink'; import { ScrollableList, type ScrollableListRef, } from '../src/ui/components/shared/ScrollableList.js'; import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js'; import { MouseProvider } from '../src/ui/contexts/MouseContext.js'; import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; import { enableMouseEvents, disableMouseEvents, } from '../src/ui/utils/mouse.js'; interface Item { id: string; title: string; } const getLorem = (index: number) => Array(10) .fill(null) .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim()) .join('\n'); const Demo = () => { const { stdout } = useStdout(); const [size, setSize] = useState({ columns: stdout.columns, rows: stdout.rows, }); useEffect(() => { const onResize = () => { setSize({ columns: stdout.columns, rows: stdout.rows, }); }; stdout.on('resize', onResize); return () => { stdout.off('resize', onResize); }; }, [stdout]); const [items, setItems] = useState(() => Array.from({ length: 1000 }, (_, i) => ({ id: String(i), title: `Item ${i + 1}`, })), ); const listRef = useRef>(null); useInput((input, key) => { if (input === 'a' || input === 'A') { setItems((prev) => [ ...prev, { id: String(prev.length), title: `Item ${prev.length + 1}` }, ]); } if ((input === 'e' || input === 'E') && !key.ctrl) { setItems((prev) => { if (prev.length === 0) return prev; const lastIndex = prev.length - 1; const lastItem = prev[lastIndex]!; const newItem = { ...lastItem, title: lastItem.title + 'e' }; return [...prev.slice(0, lastIndex), newItem]; }); } if (key.ctrl && input === 'e') { listRef.current?.scrollToEnd(); } // Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed. // For alternate buffer, explicit handling is often safer for cleanup. if (key.escape || (key.ctrl && input === 'c')) { process.exit(0); } }); return ( Press 'A' to add an item. Press 'E' to edit last item. Press 'Ctrl+E' to scroll to end. Press 'Esc' to exit. Mouse wheel or Shift+Up/Down to scroll. ( {item.title} } > {item.title} {getLorem(index)} )} estimatedItemHeight={() => 14} keyExtractor={(item) => item.id} hasFocus={true} initialScrollIndex={Number.MAX_SAFE_INTEGER} initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER} /> Count: {items.length} ); }; // Enable mouse reporting before rendering enableMouseEvents(); // Ensure cleanup happens on exit process.on('exit', () => { disableMouseEvents(); }); // Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time process.on('SIGINT', () => { process.exit(0); }); render(, { alternateBuffer: true }); ================================================ FILE: packages/cli/index.ts ================================================ #!/usr/bin/env -S node --no-warnings=DEP0040 /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { main } from './src/gemini.js'; import { FatalError, writeToStderr } from '@google/gemini-cli-core'; import { runExitCleanup } from './src/utils/cleanup.js'; // --- Global Entry Point --- // Suppress known race condition error in node-pty on Windows // Tracking bug: https://github.com/microsoft/node-pty/issues/827 process.on('uncaughtException', (error) => { if ( process.platform === 'win32' && error instanceof Error && error.message === 'Cannot resize a pty that has already exited' ) { // This error happens on Windows with node-pty when resizing a pty that has just exited. // It is a race condition in node-pty that we cannot prevent, so we silence it. return; } // For other errors, we rely on the default behavior, but since we attached a listener, // we must manually replicate it. if (error instanceof Error) { writeToStderr(error.stack + '\n'); } else { writeToStderr(String(error) + '\n'); } process.exit(1); }); main().catch(async (error) => { // Set a timeout to force exit if cleanup hangs const cleanupTimeout = setTimeout(() => { writeToStderr('Cleanup timed out, forcing exit...\n'); process.exit(1); }, 5000); try { await runExitCleanup(); } catch (cleanupError) { writeToStderr( `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`, ); } finally { clearTimeout(cleanupTimeout); } if (error instanceof FatalError) { let errorMessage = error.message; if (!process.env['NO_COLOR']) { errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; } writeToStderr(errorMessage + '\n'); process.exit(error.exitCode); } writeToStderr('An unexpected critical error occurred:'); if (error instanceof Error) { writeToStderr(error.stack + '\n'); } else { writeToStderr(String(error) + '\n'); } process.exit(1); }); ================================================ FILE: packages/cli/package.json ================================================ { "name": "@google/gemini-cli", "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "type": "module", "main": "dist/index.js", "bin": { "gemini": "dist/index.js" }, "scripts": { "build": "node ../../scripts/build_package.js", "start": "node dist/index.js", "debug": "node --inspect-brk dist/index.js", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", "posttest": "npm run build", "typecheck": "tsc --noEmit" }, "files": [ "dist" ], "config": { "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", "chalk": "^4.1.2", "cli-spinners": "^2.9.2", "clipboardy": "~5.2.0", "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^8.0.3", "dotenv": "^17.1.0", "extract-zip": "^2.0.1", "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", "ink": "npm:@jrichman/ink@6.4.11", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", "prompts": "^2.4.2", "proper-lockfile": "^4.1.2", "react": "^19.2.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "string-width": "^8.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.8", "tinygradient": "^1.1.5", "undici": "^7.10.0", "ws": "^8.16.0", "yargs": "^17.7.2", "zod": "^3.23.8" }, "devDependencies": { "@google/gemini-cli-test-utils": "file:../test-utils", "@types/command-exists": "^1.2.3", "@types/hast": "^3.0.4", "@types/node": "^20.11.24", "@types/react": "^19.2.0", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/ws": "^8.5.10", "@types/yargs": "^17.0.32", "@xterm/headless": "^5.5.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, "engines": { "node": ">=20" } } ================================================ FILE: packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = ` "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Loop test"} {"type":"error","timestamp":"","severity":"warning","message":"Loop detected, stopping execution"} {"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = ` "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Max turns test"} {"type":"error","timestamp":"","severity":"error","message":"Maximum session turns exceeded"} {"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = ` "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Stream test"} {"type":"message","timestamp":"","role":"assistant","content":"Thinking...","delta":true} {"type":"tool_use","timestamp":"","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}} {"type":"tool_result","timestamp":"","tool_id":"tool-1","status":"success","output":"Tool executed successfully"} {"type":"message","timestamp":"","role":"assistant","content":"Final answer","delta":true} {"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` "Use mock tool Use mock tool again Finished. " `; ================================================ FILE: packages/cli/src/acp/acpClient.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, type Mocked, } from 'vitest'; import { GeminiAgent, Session } from './acpClient.js'; import type { CommandHandler } from './commandHandler.js'; import * as acp from '@agentclientprotocol/sdk'; import { AuthType, ToolConfirmationOutcome, StreamEventType, isWithinRoot, ReadManyFilesTool, type GeminiChat, type Config, type MessageBus, LlmRole, type GitService, } from '@google/gemini-cli-core'; import { SettingScope, type LoadedSettings, loadSettings, } from '../config/settings.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js'; vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), })); vi.mock('../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn(), }; }); vi.mock('node:crypto', () => ({ randomUUID: () => 'test-session-id', })); vi.mock('node:fs/promises'); vi.mock('node:path', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolve: vi.fn(), }; }); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: { name: 'memory', action: vi.fn(), }, })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: vi.fn().mockReturnValue({ name: 'extensions', action: vi.fn(), }), })); vi.mock('../ui/commands/restoreCommand.js', () => ({ restoreCommand: vi.fn().mockReturnValue({ name: 'restore', action: vi.fn(), }), })); vi.mock('../ui/commands/initCommand.js', () => ({ initCommand: { name: 'init', action: vi.fn(), }, })); vi.mock( '@google/gemini-cli-core', async ( importOriginal: () => Promise, ) => { const actual = await importOriginal(); return { ...actual, ReadManyFilesTool: vi.fn().mockImplementation(() => ({ name: 'read_many_files', kind: 'read', build: vi.fn().mockReturnValue({ getDescription: () => 'Read files', toolLocations: () => [], execute: vi.fn().mockResolvedValue({ llmContent: ['--- file.txt ---\n\nFile content\n\n'], }), }), })), logToolCall: vi.fn(), isWithinRoot: vi.fn().mockReturnValue(true), LlmRole: { MAIN: 'main', SUBAGENT: 'subagent', UTILITY_TOOL: 'utility_tool', UTILITY_COMPRESSOR: 'utility_compressor', UTILITY_SUMMARIZER: 'utility_summarizer', UTILITY_ROUTER: 'utility_router', UTILITY_LOOP_DETECTOR: 'utility_loop_detector', UTILITY_NEXT_SPEAKER: 'utility_next_speaker', UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector', UTILITY_AUTOCOMPLETE: 'utility_autocomplete', UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper', }, CoreToolCallStatus: { Validating: 'validating', Scheduled: 'scheduled', Error: 'error', Success: 'success', Executing: 'executing', Cancelled: 'cancelled', AwaitingApproval: 'awaiting_approval', }, }; }, ); // Helper to create mock streams // eslint-disable-next-line @typescript-eslint/no-explicit-any async function* createMockStream(items: any[]) { for (const item of items) { yield item; } } describe('GeminiAgent', () => { let mockConfig: Mocked>>; let mockSettings: Mocked; let mockArgv: CliArgs; let mockConnection: Mocked; let agent: GeminiAgent; beforeEach(() => { mockConfig = { refreshAuth: vi.fn(), initialize: vi.fn(), waitForMcpInit: vi.fn(), getFileSystemService: vi.fn(), setFileSystemService: vi.fn(), getContentGeneratorConfig: vi.fn(), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getModel: vi.fn().mockReturnValue('gemini-pro'), getGeminiClient: vi.fn().mockReturnValue({ startChat: vi.fn().mockResolvedValue({}), }), getMessageBus: vi.fn().mockReturnValue({ publish: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), }), getApprovalMode: vi.fn().mockReturnValue('default'), isPlanEnabled: vi.fn().mockReturnValue(true), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), get config() { return this; }, } as unknown as Mocked>>; mockSettings = { merged: { security: { auth: { selectedType: 'login_with_google' } }, mcpServers: {}, }, setValue: vi.fn(), } as unknown as Mocked; mockArgv = {} as unknown as CliArgs; mockConnection = { sessionUpdate: vi.fn(), } as unknown as Mocked; (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig); (loadSettings as unknown as Mock).mockImplementation(() => ({ merged: { security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, mcpServers: {}, }, setValue: vi.fn(), })); agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); }); it('should initialize correctly', async () => { const response = await agent.initialize({ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, protocolVersion: 1, }); expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION); expect(response.authMethods).toHaveLength(4); const gatewayAuth = response.authMethods?.find( (m) => m.id === AuthType.GATEWAY, ); expect(gatewayAuth?._meta).toEqual({ gateway: { protocol: 'google', restartRequired: 'false', }, }); const geminiAuth = response.authMethods?.find( (m) => m.id === AuthType.USE_GEMINI, ); expect(geminiAuth?._meta).toEqual({ 'api-key': { provider: 'google', }, }); expect(response.agentCapabilities?.loadSession).toBe(true); }); it('should authenticate correctly', async () => { await agent.authenticate({ methodId: AuthType.LOGIN_WITH_GOOGLE, }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, undefined, undefined, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'security.auth.selectedType', AuthType.LOGIN_WITH_GOOGLE, ); }); it('should authenticate correctly with api-key in _meta', async () => { await agent.authenticate({ methodId: AuthType.USE_GEMINI, _meta: { 'api-key': 'test-api-key', }, } as unknown as acp.AuthenticateRequest); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.USE_GEMINI, 'test-api-key', undefined, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'security.auth.selectedType', AuthType.USE_GEMINI, ); }); it('should authenticate correctly with gateway method', async () => { await agent.authenticate({ methodId: AuthType.GATEWAY, _meta: { gateway: { baseUrl: 'https://example.com', headers: { Authorization: 'Bearer token' }, }, }, } as unknown as acp.AuthenticateRequest); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.GATEWAY, undefined, 'https://example.com', { Authorization: 'Bearer token' }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'security.auth.selectedType', AuthType.GATEWAY, ); }); it('should throw acp.RequestError when gateway payload is malformed', async () => { await expect( agent.authenticate({ methodId: AuthType.GATEWAY, _meta: { gateway: { // Invalid baseUrl baseUrl: 123, headers: { Authorization: 'Bearer token' }, }, }, } as unknown as acp.AuthenticateRequest), ).rejects.toThrow(/Malformed gateway payload/); }); it('should create a new session', async () => { vi.useFakeTimers(); mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', }); const response = await agent.newSession({ cwd: '/tmp', mcpServers: [], }); expect(response.sessionId).toBe('test-session-id'); expect(loadCliConfig).toHaveBeenCalled(); expect(mockConfig.initialize).toHaveBeenCalled(); expect(mockConfig.getGeminiClient).toHaveBeenCalled(); // Verify deferred call await vi.runAllTimersAsync(); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'available_commands_update', }), }), ); vi.useRealTimers(); }); it('should return modes without plan mode when plan is disabled', async () => { mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', }); mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false); mockConfig.getApprovalMode = vi.fn().mockReturnValue('default'); const response = await agent.newSession({ cwd: '/tmp', mcpServers: [], }); expect(response.modes).toEqual({ availableModes: [ { id: 'default', name: 'Default', description: 'Prompts for approval' }, { id: 'autoEdit', name: 'Auto Edit', description: 'Auto-approves edit tools', }, { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, ], currentModeId: 'default', }); expect(response.models).toEqual({ availableModels: expect.arrayContaining([ expect.objectContaining({ modelId: 'auto-gemini-2.5', name: 'Auto (Gemini 2.5)', }), ]), currentModelId: 'gemini-pro', }); }); it('should include preview models when user has access', async () => { mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); const response = await agent.newSession({ cwd: '/tmp', mcpServers: [], }); expect(response.models?.availableModels).toEqual( expect.arrayContaining([ expect.objectContaining({ modelId: 'auto-gemini-3', name: expect.stringContaining('Auto'), }), expect.objectContaining({ modelId: 'gemini-3.1-pro-preview', name: 'gemini-3.1-pro-preview', }), ]), ); }); it('should return modes with plan mode when plan is enabled', async () => { mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', }); mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true); mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan'); const response = await agent.newSession({ cwd: '/tmp', mcpServers: [], }); expect(response.modes).toEqual({ availableModes: [ { id: 'default', name: 'Default', description: 'Prompts for approval' }, { id: 'autoEdit', name: 'Auto Edit', description: 'Auto-approves edit tools', }, { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, { id: 'plan', name: 'Plan', description: 'Read-only mode' }, ], currentModeId: 'plan', }); expect(response.models).toEqual({ availableModels: expect.arrayContaining([ expect.objectContaining({ modelId: 'auto-gemini-2.5', name: 'Auto (Gemini 2.5)', }), ]), currentModelId: 'gemini-pro', }); }); it('should fail session creation if Gemini API key is missing', async () => { (loadSettings as unknown as Mock).mockImplementation(() => ({ merged: { security: { auth: { selectedType: AuthType.USE_GEMINI } }, mcpServers: {}, }, setValue: vi.fn(), })); mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: undefined, }); await expect( agent.newSession({ cwd: '/tmp', mcpServers: [], }), ).rejects.toMatchObject({ message: 'Gemini API key is missing or not configured.', }); }); it('should create a new session with mcp servers', async () => { const mcpServers = [ { name: 'test-server', command: 'node', args: ['server.js'], env: [{ name: 'KEY', value: 'VALUE' }], }, ]; await agent.newSession({ cwd: '/tmp', mcpServers, }); expect(loadCliConfig).toHaveBeenCalledWith( expect.objectContaining({ mcpServers: expect.objectContaining({ 'test-server': expect.objectContaining({ command: 'node', args: ['server.js'], env: { KEY: 'VALUE' }, }), }), }), 'test-session-id', mockArgv, { cwd: '/tmp' }, ); }); it('should handle authentication failure gracefully', async () => { mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed')); const debugSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Should throw RequestError with custom message await expect( agent.newSession({ cwd: '/tmp', mcpServers: [], }), ).rejects.toMatchObject({ message: 'Auth failed', }); debugSpy.mockRestore(); }); it('should initialize file system service if client supports it', async () => { agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); await agent.initialize({ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, protocolVersion: 1, }); await agent.newSession({ cwd: '/tmp', mcpServers: [], }); expect(mockConfig.setFileSystemService).toHaveBeenCalled(); }); it('should cancel a session', async () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); // Mock the session's cancelPendingPrompt const session = ( agent as unknown as { sessions: Map } ).sessions.get('test-session-id'); if (!session) throw new Error('Session not found'); session.cancelPendingPrompt = vi.fn(); await agent.cancel({ sessionId: 'test-session-id' }); expect(session.cancelPendingPrompt).toHaveBeenCalled(); }); it('should throw error when cancelling non-existent session', async () => { await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow( 'Session not found', ); }); it('should delegate prompt to session', async () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); const session = ( agent as unknown as { sessions: Map } ).sessions.get('test-session-id'); if (!session) throw new Error('Session not found'); session.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); const result = await agent.prompt({ sessionId: 'test-session-id', prompt: [], }); expect(session.prompt).toHaveBeenCalled(); expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should delegate setMode to session', async () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); const session = ( agent as unknown as { sessions: Map } ).sessions.get('test-session-id'); if (!session) throw new Error('Session not found'); session.setMode = vi.fn().mockReturnValue({}); const result = await agent.setSessionMode({ sessionId: 'test-session-id', modeId: 'plan', }); expect(session.setMode).toHaveBeenCalledWith('plan'); expect(result).toEqual({}); }); it('should throw error when setting mode on non-existent session', async () => { await expect( agent.setSessionMode({ sessionId: 'unknown', modeId: 'plan', }), ).rejects.toThrow('Session not found: unknown'); }); it('should delegate setModel to session (unstable)', async () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); const session = ( agent as unknown as { sessions: Map } ).sessions.get('test-session-id'); if (!session) throw new Error('Session not found'); session.setModel = vi.fn().mockReturnValue({}); const result = await agent.unstable_setSessionModel({ sessionId: 'test-session-id', modelId: 'gemini-2.0-pro-exp', }); expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp'); expect(result).toEqual({}); }); it('should throw error when setting model on non-existent session (unstable)', async () => { await expect( agent.unstable_setSessionModel({ sessionId: 'unknown', modelId: 'gemini-2.0-pro-exp', }), ).rejects.toThrow('Session not found: unknown'); }); }); describe('Session', () => { let mockChat: Mocked; let mockConfig: Mocked; let mockConnection: Mocked; let session: Session; let mockToolRegistry: { getTool: Mock }; let mockTool: { kind: string; build: Mock }; let mockMessageBus: Mocked; beforeEach(() => { mockChat = { sendMessageStream: vi.fn(), addHistory: vi.fn(), recordCompletedToolCalls: vi.fn(), } as unknown as Mocked; mockTool = { kind: 'read', build: vi.fn().mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(null), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), }), }; mockToolRegistry = { getTool: vi.fn().mockReturnValue(mockTool), }; mockMessageBus = { publish: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), } as unknown as Mocked; mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ shouldIgnoreFile: vi.fn().mockReturnValue(false), }), getFileFilteringOptions: vi.fn().mockReturnValue({}), getTargetDir: vi.fn().mockReturnValue('/tmp'), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(mockMessageBus), setApprovalMode: vi.fn(), setModel: vi.fn(), isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), get config() { return this; }, get toolRegistry() { return mockToolRegistry; }, } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), requestPermission: vi.fn(), sendNotification: vi.fn(), } as unknown as Mocked; session = new Session('session-1', mockChat, mockConfig, mockConnection, { system: { settings: {} }, systemDefaults: { settings: {} }, user: { settings: {} }, workspace: { settings: {} }, merged: { settings: {} }, errors: [], } as unknown as LoadedSettings); }); afterEach(() => { vi.clearAllMocks(); }); it('should send available commands', async () => { await session.sendAvailableCommands(); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'available_commands_update', availableCommands: expect.arrayContaining([ expect.objectContaining({ name: 'memory' }), expect.objectContaining({ name: 'extensions' }), expect.objectContaining({ name: 'restore' }), expect.objectContaining({ name: 'init' }), ]), }), }), ); }); it('should await MCP initialization before processing a prompt', async () => { const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'test' }], }); expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce(); const waitOrder = (mockConfig.waitForMcpInit as Mock).mock .invocationCallOrder[0]; const sendOrder = (mockChat.sendMessageStream as Mock).mock .invocationCallOrder[0]; expect(waitOrder).toBeLessThan(sendOrder); }); it('should handle prompt with text response', async () => { const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [{ content: { parts: [{ text: 'Hello' }] } }], }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Hi' }], }); expect(mockChat.sendMessageStream).toHaveBeenCalled(); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({ sessionId: 'session-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' }, }, }); expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle /memory command', async () => { const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: '/memory view' }], }); expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/memory view', expect.any(Object), ); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle /extensions command', async () => { const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: '/extensions list' }], }); expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions list', expect.any(Object), ); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle /extensions explore command', async () => { const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: '/extensions explore' }], }); expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions explore', expect.any(Object), ); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle /restore command', async () => { const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: '/restore' }], }); expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/restore', expect.any(Object), ); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle /init command', async () => { const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: '/init' }], }); expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object)); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle tool calls', async () => { const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: { foo: 'bar' } }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [{ content: { parts: [{ text: 'Result' }] } }], }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); const result = await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool'); expect(mockTool.build).toHaveBeenCalledWith({ foo: 'bar' }); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call', status: 'in_progress', kind: 'read', }), }), ); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', title: 'Test Tool', locations: [], kind: 'read', }), }), ); expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle tool call permission request', async () => { const confirmationDetails = { type: 'info', onConfirm: vi.fn(), }; mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), }); mockConnection.requestPermission.mockResolvedValue({ outcome: { outcome: 'selected', optionId: ToolConfirmationOutcome.ProceedOnce, }, }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockConnection.requestPermission).toHaveBeenCalled(); expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( ToolConfirmationOutcome.ProceedOnce, ); }); it('should exclude always allow options when disableAlwaysAllow is true', async () => { mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true); const confirmationDetails = { type: 'info', onConfirm: vi.fn(), }; mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), }); mockConnection.requestPermission.mockResolvedValue({ outcome: { outcome: 'selected', optionId: ToolConfirmationOutcome.ProceedOnce, }, }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockConnection.requestPermission).toHaveBeenCalledWith( expect.objectContaining({ options: expect.not.arrayContaining([ expect.objectContaining({ optionId: ToolConfirmationOutcome.ProceedAlways, }), ]), }), ); }); it('should use filePath for ACP diff content in permission request', async () => { const confirmationDetails = { type: 'edit', title: 'Confirm Write: test.txt', fileName: 'test.txt', filePath: '/tmp/test.txt', originalContent: 'old', newContent: 'new', onConfirm: vi.fn(), }; mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), }); mockConnection.requestPermission.mockResolvedValue({ outcome: { outcome: 'selected', optionId: ToolConfirmationOutcome.ProceedOnce, }, }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockConnection.requestPermission).toHaveBeenCalledWith( expect.objectContaining({ toolCall: expect.objectContaining({ content: expect.arrayContaining([ expect.objectContaining({ type: 'diff', path: '/tmp/test.txt', oldText: 'old', newText: 'new', }), ]), }), }), ); }); it('should use filePath for ACP diff content in tool result', async () => { mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(null), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result', returnDisplay: { fileName: 'test.txt', filePath: '/tmp/test.txt', originalContent: 'old', newContent: 'new', }, }), }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); const updateCalls = mockConnection.sessionUpdate.mock.calls.map( (call) => call[0], ); const toolCallUpdate = updateCalls.find( (call) => call.update?.sessionUpdate === 'tool_call_update', ); expect(toolCallUpdate).toEqual( expect.objectContaining({ update: expect.objectContaining({ content: expect.arrayContaining([ expect.objectContaining({ type: 'diff', path: '/tmp/test.txt', oldText: 'old', newText: 'new', }), ]), }), }), ); }); it('should handle tool call cancellation by user', async () => { const confirmationDetails = { type: 'info', onConfirm: vi.fn(), }; mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), }); mockConnection.requestPermission.mockResolvedValue({ outcome: { outcome: 'cancelled' }, }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); // When cancelled, it sends an error response to the model // We can verify that the second call to sendMessageStream contains the error expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); const secondCallArgs = mockChat.sendMessageStream.mock.calls[1]; const parts = secondCallArgs[1]; // parts expect(parts).toEqual( expect.arrayContaining([ expect.objectContaining({ functionResponse: expect.objectContaining({ response: { error: expect.stringContaining('canceled by the user'), }, }), }), ]), ); }); it('should include _meta.kind in diff tool calls', async () => { // Test 'add' (no original content) const addConfirmation = { type: 'edit', fileName: 'new.txt', originalContent: null, newContent: 'New content', onConfirm: vi.fn(), }; // Test 'modify' (original and new content) const modifyConfirmation = { type: 'edit', fileName: 'existing.txt', originalContent: 'Old content', newContent: 'New content', onConfirm: vi.fn(), }; // Test 'delete' (original content, no new content) const deleteConfirmation = { type: 'edit', fileName: 'deleted.txt', originalContent: 'Old content', newContent: '', onConfirm: vi.fn(), }; const mockBuild = vi.fn(); mockTool.build = mockBuild; // Helper to simulate tool call and check permission request // eslint-disable-next-line @typescript-eslint/no-explicit-any const checkDiffKind = async (confirmation: any, expectedKind: string) => { mockBuild.mockReturnValueOnce({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation), execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }), }); mockConnection.requestPermission.mockResolvedValueOnce({ outcome: { outcome: 'selected', optionId: ToolConfirmationOutcome.ProceedOnce, }, }); const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const emptyStream = createMockStream([]); mockChat.sendMessageStream .mockResolvedValueOnce(stream) .mockResolvedValueOnce(emptyStream); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockConnection.requestPermission).toHaveBeenCalledWith( expect.objectContaining({ toolCall: expect.objectContaining({ content: expect.arrayContaining([ expect.objectContaining({ type: 'diff', _meta: { kind: expectedKind }, }), ]), }), }), ); }; await checkDiffKind(addConfirmation, 'add'); await checkDiffKind(modifyConfirmation, 'modify'); await checkDiffKind(deleteConfirmation, 'delete'); }); it('should handle @path resolution', async () => { (path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt'); (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => false, }); (isWithinRoot as unknown as Mock).mockReturnValue(true); const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); await session.prompt({ sessionId: 'session-1', prompt: [ { type: 'text', text: 'Read' }, { type: 'resource_link', uri: 'file://file.txt', mimeType: 'text/plain', name: 'file.txt', }, ], }); expect(path.resolve).toHaveBeenCalled(); expect(fs.stat).toHaveBeenCalled(); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', title: 'Read files', locations: [], kind: 'read', }), }), ); // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream expect(mockChat.sendMessageStream).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ expect.objectContaining({ text: expect.stringContaining('Content from @file.txt'), }), ]), expect.anything(), expect.any(AbortSignal), LlmRole.MAIN, ); }); it('should handle @path resolution error', async () => { (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt'); (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => false, }); (isWithinRoot as unknown as Mock).mockReturnValue(true); const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; MockReadManyFilesTool.mockImplementationOnce(() => ({ name: 'read_many_files', kind: 'read', build: vi.fn().mockReturnValue({ getDescription: () => 'Read files', toolLocations: () => [], execute: vi.fn().mockRejectedValue(new Error('File read failed')), }), })); const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); await expect( session.prompt({ sessionId: 'session-1', prompt: [ { type: 'text', text: 'Read' }, { type: 'resource_link', uri: 'file://error.txt', mimeType: 'text/plain', name: 'error.txt', }, ], }), ).rejects.toThrow('File read failed'); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'failed', content: expect.arrayContaining([ expect.objectContaining({ content: expect.objectContaining({ text: expect.stringMatching(/File read failed/), }), }), ]), kind: 'read', }), }), ); }); it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ start(controller) { streamController = controller; }, }); let streamStarted: (value: unknown) => void; const streamStartedPromise = new Promise((resolve) => { streamStarted = resolve; }); // Adapt web stream to async iterable async function* asyncStream() { process.stdout.write('TEST: asyncStream started\n'); streamStarted(true); const reader = stream.getReader(); try { while (true) { process.stdout.write('TEST: waiting for read\n'); const { done, value } = await reader.read(); process.stdout.write(`TEST: read returned done=${done}\n`); if (done) break; yield value; } } finally { process.stdout.write('TEST: releasing lock\n'); reader.releaseLock(); } } mockChat.sendMessageStream.mockResolvedValue(asyncStream()); process.stdout.write('TEST: calling prompt\n'); const promptPromise = session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Hi' }], }); process.stdout.write('TEST: waiting for streamStarted\n'); await streamStartedPromise; process.stdout.write('TEST: streamStarted\n'); await session.cancelPendingPrompt(); process.stdout.write('TEST: cancelled\n'); // Close the stream to allow prompt loop to continue and check aborted signal streamController!.close(); process.stdout.write('TEST: stream closed\n'); const result = await promptPromise; process.stdout.write(`TEST: result received ${JSON.stringify(result)}\n`); expect(result).toEqual({ stopReason: 'cancelled' }); }); it('should handle rate limit error', async () => { const error = new Error('Rate limit'); (error as unknown as { status: number }).status = 429; mockChat.sendMessageStream.mockRejectedValue(error); await expect( session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Hi' }], }), ).rejects.toMatchObject({ code: 429, message: 'Rate limit exceeded. Try again later.', }); }); it('should handle tool execution error', async () => { mockTool.build.mockReturnValue({ getDescription: () => 'Test Tool', toolLocations: () => [], shouldConfirmExecute: vi.fn().mockResolvedValue(null), execute: vi.fn().mockRejectedValue(new Error('Tool failed')), }); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'test_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'failed', content: expect.arrayContaining([ expect.objectContaining({ content: expect.objectContaining({ text: 'Tool failed' }), }), ]), kind: 'read', }), }), ); }); it('should handle missing tool', async () => { mockToolRegistry.getTool.mockReturnValue(undefined); const stream1 = createMockStream([ { type: StreamEventType.CHUNK, value: { functionCalls: [{ name: 'unknown_tool', args: {} }], }, }, ]); const stream2 = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream .mockResolvedValueOnce(stream1) .mockResolvedValueOnce(stream2); await session.prompt({ sessionId: 'session-1', prompt: [{ type: 'text', text: 'Call tool' }], }); // Should send error response to model expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); const secondCallArgs = mockChat.sendMessageStream.mock.calls[1]; const parts = secondCallArgs[1]; expect(parts).toEqual( expect.arrayContaining([ expect.objectContaining({ functionResponse: expect.objectContaining({ response: { error: expect.stringContaining('not found in registry'), }, }), }), ]), ); }); it('should ignore files based on configuration', async () => { ( mockConfig.getFileService().shouldIgnoreFile as unknown as Mock ).mockReturnValue(true); const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); await session.prompt({ sessionId: 'session-1', prompt: [ { type: 'resource_link', uri: 'file://ignored.txt', mimeType: 'text/plain', name: 'ignored.txt', }, ], }); // Should not read file expect(mockToolRegistry.getTool).not.toHaveBeenCalledWith( 'read_many_files', ); }); it('should handle directory resolution with glob', async () => { (path.resolve as unknown as Mock).mockReturnValue('/tmp/dir'); (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => true, }); (isWithinRoot as unknown as Mock).mockReturnValue(true); const stream = createMockStream([ { type: StreamEventType.CHUNK, value: { candidates: [] }, }, ]); mockChat.sendMessageStream.mockResolvedValue(stream); await session.prompt({ sessionId: 'session-1', prompt: [ { type: 'resource_link', uri: 'file://dir', mimeType: 'text/plain', name: 'dir', }, ], }); // Should use glob // ReadManyFilesTool is instantiated directly, so we check if the mock instance's build method was called const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; const mockInstance = MockReadManyFilesTool.mock.results[ MockReadManyFilesTool.mock.results.length - 1 ].value; expect(mockInstance.build).toHaveBeenCalled(); }); it('should set mode on config', () => { session.setMode(ApprovalMode.AUTO_EDIT); expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, ); }); it('should throw error for invalid mode', () => { expect(() => session.setMode('invalid-mode')).toThrow( 'Invalid or unavailable mode: invalid-mode', ); }); it('should set model on config', () => { session.setModel('gemini-2.0-flash-exp'); expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp'); }); it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => { // Mock handleCommand to verify it gets called const handleCommandSpy = vi .spyOn( (session as unknown as { commandHandler: CommandHandler }) .commandHandler, 'handleCommand', ) .mockResolvedValue(true); await session.prompt({ sessionId: 'session-1', prompt: [ { type: 'text', text: '' }, { type: 'text', text: '/memory' }, ], }); expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything()); }); }); ================================================ FILE: packages/cli/src/acp/acpClient.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type Config, type GeminiChat, type ToolResult, type ToolCallConfirmationDetails, type FilterFilesOptions, type ConversationRecord, CoreToolCallStatus, AuthType, logToolCall, convertToFunctionResponse, ToolConfirmationOutcome, clearCachedCredentialFile, isNodeError, getErrorMessage, isWithinRoot, getErrorStatus, MCPServerConfig, DiscoveredMCPTool, StreamEventType, ToolCallEvent, debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, resolveModel, createWorkingStdio, startupProfiler, Kind, partListUnionToString, LlmRole, ApprovalMode, getVersion, convertSessionToClientHistory, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; import { getAcpErrorMessage } from './acpErrors.js'; import { Readable, Writable } from 'node:stream'; function hasMeta(obj: unknown): obj is { _meta?: Record } { return typeof obj === 'object' && obj !== null && '_meta' in obj; } import type { Content, Part, FunctionCall } from '@google/genai'; import { SettingScope, loadSettings, type LoadedSettings, } from '../config/settings.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import { loadCliConfig, type CliArgs } from '../config/config.js'; import { runExitCleanup } from '../utils/cleanup.js'; import { SessionSelector } from '../utils/sessionUtils.js'; import { CommandHandler } from './commandHandler.js'; export async function runAcpClient( config: Config, settings: LoadedSettings, argv: CliArgs, ) { // ... (skip unchanged lines) ... const { stdout: workingStdout } = createWorkingStdio(); const stdout = Writable.toWeb(workingStdout) as WritableStream; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const stdin = Readable.toWeb(process.stdin) as ReadableStream; const stream = acp.ndJsonStream(stdout, stdin); const connection = new acp.AgentSideConnection( (connection) => new GeminiAgent(config, settings, argv, connection), stream, ); // SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes. // We must explicitly await the connection close to flush telemetry. // Use finally() to ensure cleanup runs even on stream errors. await connection.closed.finally(runExitCleanup); } export class GeminiAgent { private sessions: Map = new Map(); private clientCapabilities: acp.ClientCapabilities | undefined; private apiKey: string | undefined; private baseUrl: string | undefined; private customHeaders: Record | undefined; constructor( private context: AgentLoopContext, private settings: LoadedSettings, private argv: CliArgs, private connection: acp.AgentSideConnection, ) {} async initialize( args: acp.InitializeRequest, ): Promise { this.clientCapabilities = args.clientCapabilities; const authMethods = [ { id: AuthType.LOGIN_WITH_GOOGLE, name: 'Log in with Google', description: 'Log in with your Google account', }, { id: AuthType.USE_GEMINI, name: 'Gemini API key', description: 'Use an API key with Gemini Developer API', _meta: { 'api-key': { provider: 'google', }, }, }, { id: AuthType.USE_VERTEX_AI, name: 'Vertex AI', description: 'Use an API key with Vertex AI GenAI API', }, { id: AuthType.GATEWAY, name: 'AI API Gateway', description: 'Use a custom AI API Gateway', _meta: { gateway: { protocol: 'google', restartRequired: 'false', }, }, }, ]; await this.context.config.initialize(); const version = await getVersion(); return { protocolVersion: acp.PROTOCOL_VERSION, authMethods, agentInfo: { name: 'gemini-cli', title: 'Gemini CLI', version, }, agentCapabilities: { loadSession: true, promptCapabilities: { image: true, audio: true, embeddedContext: true, }, mcpCapabilities: { http: true, sse: true, }, }, }; } async authenticate(req: acp.AuthenticateRequest): Promise { const { methodId } = req; const method = z.nativeEnum(AuthType).parse(methodId); const selectedAuthType = this.settings.merged.security.auth.selectedType; // Only clear credentials when switching to a different auth method if (selectedAuthType && selectedAuthType !== method) { await clearCachedCredentialFile(); } // Check for api-key in _meta const meta = hasMeta(req) ? req._meta : undefined; const apiKey = typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined; // Refresh auth with the requested method // This will reuse existing credentials if they're valid, // or perform new authentication if needed try { if (apiKey) { this.apiKey = apiKey; } // Extract gateway details if present const gatewaySchema = z.object({ baseUrl: z.string().optional(), headers: z.record(z.string()).optional(), }); let baseUrl: string | undefined; let headers: Record | undefined; if (meta?.['gateway']) { const result = gatewaySchema.safeParse(meta['gateway']); if (result.success) { baseUrl = result.data.baseUrl; headers = result.data.headers; } else { throw new acp.RequestError( -32602, `Malformed gateway payload: ${result.error.message}`, ); } } this.baseUrl = baseUrl; this.customHeaders = headers; await this.context.config.refreshAuth( method, apiKey ?? this.apiKey, baseUrl, headers, ); } catch (e) { throw new acp.RequestError(-32000, getAcpErrorMessage(e)); } this.settings.setValue( SettingScope.User, 'security.auth.selectedType', method, ); } async newSession({ cwd, mcpServers, }: acp.NewSessionRequest): Promise { const sessionId = randomUUID(); const loadedSettings = loadSettings(cwd); const config = await this.newSessionConfig( sessionId, cwd, mcpServers, loadedSettings, ); const authType = loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI; let isAuthenticated = false; let authErrorMessage = ''; try { await config.refreshAuth( authType, this.apiKey, this.baseUrl, this.customHeaders, ); isAuthenticated = true; // Extra validation for Gemini API key const contentGeneratorConfig = config.getContentGeneratorConfig(); if ( authType === AuthType.USE_GEMINI && (!contentGeneratorConfig || !contentGeneratorConfig.apiKey) ) { isAuthenticated = false; authErrorMessage = 'Gemini API key is missing or not configured.'; } } catch (e) { isAuthenticated = false; authErrorMessage = getAcpErrorMessage(e); debugLogger.error( `Authentication failed: ${e instanceof Error ? e.stack : e}`, ); } if (!isAuthenticated) { throw new acp.RequestError( -32000, authErrorMessage || 'Authentication required.', ); } if (this.clientCapabilities?.fs) { const acpFileSystemService = new AcpFileSystemService( this.connection, sessionId, this.clientCapabilities.fs, config.getFileSystemService(), ); config.setFileSystemService(acpFileSystemService); } await config.initialize(); startupProfiler.flush(config); const geminiClient = config.getGeminiClient(); const chat = await geminiClient.startChat(); const session = new Session( sessionId, chat, config, this.connection, this.settings, ); this.sessions.set(sessionId, session); setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.sendAvailableCommands(); }, 0); const { availableModels, currentModelId } = buildAvailableModels( config, loadedSettings, ); const response = { sessionId, modes: { availableModes: buildAvailableModes(config.isPlanEnabled()), currentModeId: config.getApprovalMode(), }, models: { availableModels, currentModelId, }, }; return response; } async loadSession({ sessionId, cwd, mcpServers, }: acp.LoadSessionRequest): Promise { const config = await this.initializeSessionConfig( sessionId, cwd, mcpServers, ); const sessionSelector = new SessionSelector(config); const { sessionData, sessionPath } = await sessionSelector.resolveSession(sessionId); if (this.clientCapabilities?.fs) { const acpFileSystemService = new AcpFileSystemService( this.connection, sessionId, this.clientCapabilities.fs, config.getFileSystemService(), ); config.setFileSystemService(acpFileSystemService); } const clientHistory = convertSessionToClientHistory(sessionData.messages); const geminiClient = config.getGeminiClient(); await geminiClient.initialize(); await geminiClient.resumeChat(clientHistory, { conversation: sessionData, filePath: sessionPath, }); const session = new Session( sessionId, geminiClient.getChat(), config, this.connection, this.settings, ); this.sessions.set(sessionId, session); // Stream history back to client // eslint-disable-next-line @typescript-eslint/no-floating-promises session.streamHistory(sessionData.messages); setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.sendAvailableCommands(); }, 0); const { availableModels, currentModelId } = buildAvailableModels( config, this.settings, ); const response = { modes: { availableModes: buildAvailableModes(config.isPlanEnabled()), currentModeId: config.getApprovalMode(), }, models: { availableModels, currentModelId, }, }; return response; } private async initializeSessionConfig( sessionId: string, cwd: string, mcpServers: acp.McpServer[], ): Promise { const selectedAuthType = this.settings.merged.security.auth.selectedType; if (!selectedAuthType) { throw acp.RequestError.authRequired(); } // 1. Create config WITHOUT initializing it (no MCP servers started yet) const config = await this.newSessionConfig(sessionId, cwd, mcpServers); // 2. Authenticate BEFORE initializing configuration or starting MCP servers. // This satisfies the security requirement to verify the user before executing // potentially unsafe server definitions. try { await config.refreshAuth( selectedAuthType, this.apiKey, this.baseUrl, this.customHeaders, ); } catch (e) { debugLogger.error(`Authentication failed: ${e}`); throw acp.RequestError.authRequired(); } // 3. Now that we are authenticated, it is safe to initialize the config // which starts the MCP servers and other heavy resources. await config.initialize(); startupProfiler.flush(config); return config; } async newSessionConfig( sessionId: string, cwd: string, mcpServers: acp.McpServer[], loadedSettings?: LoadedSettings, ): Promise { const currentSettings = loadedSettings || this.settings; const mergedMcpServers = { ...currentSettings.merged.mcpServers }; for (const server of mcpServers) { if ( 'type' in server && (server.type === 'sse' || server.type === 'http') ) { // HTTP or SSE MCP server const headers = Object.fromEntries( server.headers.map(({ name, value }) => [name, value]), ); mergedMcpServers[server.name] = new MCPServerConfig( undefined, // command undefined, // args undefined, // env undefined, // cwd server.type === 'sse' ? server.url : undefined, // url (sse) server.type === 'http' ? server.url : undefined, // httpUrl headers, ); } else if ('command' in server) { // Stdio MCP server const env: Record = {}; for (const { name: envName, value } of server.env) { env[envName] = value; } mergedMcpServers[server.name] = new MCPServerConfig( server.command, server.args, env, cwd, ); } } const settings = { ...currentSettings.merged, mcpServers: mergedMcpServers, }; const config = await loadCliConfig(settings, sessionId, this.argv, { cwd }); return config; } async cancel(params: acp.CancelNotification): Promise { const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } await session.cancelPendingPrompt(); } async prompt(params: acp.PromptRequest): Promise { const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return session.prompt(params); } async setSessionMode( params: acp.SetSessionModeRequest, ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return session.setMode(params.modeId); } async unstable_setSessionModel( params: acp.SetSessionModelRequest, ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return session.setModel(params.modelId); } } export class Session { private pendingPrompt: AbortController | null = null; private commandHandler = new CommandHandler(); constructor( private readonly id: string, private readonly chat: GeminiChat, private readonly context: AgentLoopContext, private readonly connection: acp.AgentSideConnection, private readonly settings: LoadedSettings, ) {} async cancelPendingPrompt(): Promise { if (!this.pendingPrompt) { throw new Error('Not currently generating'); } this.pendingPrompt.abort(); this.pendingPrompt = null; } setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { const availableModes = buildAvailableModes( this.context.config.isPlanEnabled(), ); const mode = availableModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`Invalid or unavailable mode: ${modeId}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion this.context.config.setApprovalMode(mode.id as ApprovalMode); return {}; } private getAvailableCommands() { return this.commandHandler.getAvailableCommands(); } async sendAvailableCommands(): Promise { const availableCommands = this.getAvailableCommands().map((command) => ({ name: command.name, description: command.description, })); await this.sendUpdate({ sessionUpdate: 'available_commands_update', availableCommands, }); } setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { this.context.config.setModel(modelId); return {}; } async streamHistory(messages: ConversationRecord['messages']): Promise { for (const msg of messages) { const contentString = partListUnionToString(msg.content); if (msg.type === 'user') { if (contentString.trim()) { await this.sendUpdate({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: contentString }, }); } } else if (msg.type === 'gemini') { // Thoughts if (msg.thoughts) { for (const thought of msg.thoughts) { const thoughtText = `**${thought.subject}**\n${thought.description}`; await this.sendUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: thoughtText }, }); } } // Message text if (contentString.trim()) { await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: contentString }, }); } // Tool calls if (msg.toolCalls) { for (const toolCall of msg.toolCalls) { const toolCallContent: acp.ToolCallContent[] = []; if (toolCall.resultDisplay) { if (typeof toolCall.resultDisplay === 'string') { toolCallContent.push({ type: 'content', content: { type: 'text', text: toolCall.resultDisplay }, }); } else if ('fileName' in toolCall.resultDisplay) { toolCallContent.push({ type: 'diff', path: toolCall.resultDisplay.fileName, oldText: toolCall.resultDisplay.originalContent, newText: toolCall.resultDisplay.newContent, }); } } const tool = this.context.toolRegistry.getTool(toolCall.name); await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: toolCall.id, status: toolCall.status === CoreToolCallStatus.Success ? 'completed' : 'failed', title: toolCall.displayName || toolCall.name, content: toolCallContent, kind: tool ? toAcpToolKind(tool.kind) : 'other', }); } } } } } async prompt(params: acp.PromptRequest): Promise { this.pendingPrompt?.abort(); const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; await this.context.config.waitForMcpInit(); const promptId = Math.random().toString(16).slice(2); const chat = this.chat; const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); // Command interception let commandText = ''; for (const part of parts) { if (typeof part === 'object' && part !== null) { if ('text' in part) { // It is a text part // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion const text = (part as any).text; if (typeof text === 'string') { commandText += text; } } else { // Non-text part (image, embedded resource) // Stop looking for command break; } } } commandText = commandText.trim(); if ( commandText && (commandText.startsWith('/') || commandText.startsWith('$')) ) { // If we found a command, pass it to handleCommand // Note: handleCommand currently expects `commandText` to be the command string // It uses `parts` argument but effectively ignores it in current implementation const handled = await this.handleCommand(commandText, parts); if (handled) { return { stopReason: 'end_turn', _meta: { quota: { token_count: { input_tokens: 0, output_tokens: 0 }, model_usage: [], }, }, }; } } let totalInputTokens = 0; let totalOutputTokens = 0; const modelUsageMap = new Map(); let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { if (pendingSend.signal.aborted) { chat.addHistory(nextMessage); return { stopReason: CoreToolCallStatus.Cancelled }; } const functionCalls: FunctionCall[] = []; try { const model = resolveModel( this.context.config.getModel(), (await this.context.config.getGemini31Launched?.()) ?? false, ); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], promptId, pendingSend.signal, LlmRole.MAIN, ); nextMessage = null; let turnInputTokens = 0; let turnOutputTokens = 0; let turnModelId = model; for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { turnInputTokens = resp.value.usageMetadata.promptTokenCount ?? turnInputTokens; turnOutputTokens = resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens; if (resp.value.modelVersion) { turnModelId = resp.value.modelVersion; } } if ( resp.type === StreamEventType.CHUNK && resp.value.candidates && resp.value.candidates.length > 0 ) { const candidate = resp.value.candidates[0]; for (const part of candidate.content?.parts ?? []) { if (!part.text) { continue; } const content: acp.ContentBlock = { type: 'text', text: part.text, }; // eslint-disable-next-line @typescript-eslint/no-floating-promises this.sendUpdate({ sessionUpdate: part.thought ? 'agent_thought_chunk' : 'agent_message_chunk', content, }); } } if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { functionCalls.push(...resp.value.functionCalls); } } totalInputTokens += turnInputTokens; totalOutputTokens += turnOutputTokens; if (turnInputTokens > 0 || turnOutputTokens > 0) { const existing = modelUsageMap.get(turnModelId) ?? { input: 0, output: 0, }; existing.input += turnInputTokens; existing.output += turnOutputTokens; modelUsageMap.set(turnModelId, existing); } if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } } catch (error) { if (getErrorStatus(error) === 429) { throw new acp.RequestError( 429, 'Rate limit exceeded. Try again later.', ); } if ( pendingSend.signal.aborted || (error instanceof Error && error.name === 'AbortError') ) { return { stopReason: CoreToolCallStatus.Cancelled }; } throw new acp.RequestError( getErrorStatus(error) || 500, getAcpErrorMessage(error), ); } if (functionCalls.length > 0) { const toolResponseParts: Part[] = []; for (const fc of functionCalls) { const response = await this.runTool(pendingSend.signal, promptId, fc); toolResponseParts.push(...response); } nextMessage = { role: 'user', parts: toolResponseParts }; } } const modelUsageArray = Array.from(modelUsageMap.entries()).map( ([modelName, counts]) => ({ model: modelName, token_count: { input_tokens: counts.input, output_tokens: counts.output, }, }), ); return { stopReason: 'end_turn', _meta: { quota: { token_count: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens, }, model_usage: modelUsageArray, }, }, }; } private async handleCommand( commandText: string, // eslint-disable-next-line @typescript-eslint/no-unused-vars parts: Part[], ): Promise { const gitService = await this.context.config.getGitService(); const commandContext = { agentContext: this.context, settings: this.settings, git: gitService, sendMessage: async (text: string) => { await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, }); }, }; return this.commandHandler.handleCommand(commandText, commandContext); } private async sendUpdate(update: acp.SessionUpdate): Promise { const params: acp.SessionNotification = { sessionId: this.id, update, }; await this.connection.sessionUpdate(params); } private async runTool( abortSignal: AbortSignal, promptId: string, fc: FunctionCall, ): Promise { const callId = fc.id ?? `${fc.name}-${Date.now()}`; const args = fc.args ?? {}; const startTime = Date.now(); const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall( this.context.config, new ToolCallEvent( undefined, fc.name ?? '', args, durationMs, false, promptId, typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool ? 'mcp' : 'native', error.message, ), ); return [ { functionResponse: { id: callId, name: fc.name ?? '', response: { error: error.message }, }, }, ]; }; if (!fc.name) { return errorResponse(new Error('Missing function name')); } const toolRegistry = this.context.toolRegistry; const tool = toolRegistry.getTool(fc.name); if (!tool) { return errorResponse( new Error(`Tool "${fc.name}" not found in registry.`), ); } try { const invocation = tool.build(args); const confirmationDetails = await invocation.shouldConfirmExecute(abortSignal); if (confirmationDetails) { const content: acp.ToolCallContent[] = []; if (confirmationDetails.type === 'edit') { content.push({ type: 'diff', path: confirmationDetails.filePath, oldText: confirmationDetails.originalContent, newText: confirmationDetails.newContent, _meta: { kind: !confirmationDetails.originalContent ? 'add' : confirmationDetails.newContent === '' ? 'delete' : 'modify', }, }); } const params: acp.RequestPermissionRequest = { sessionId: this.id, options: toPermissionOptions( confirmationDetails, this.context.config, ), toolCall: { toolCallId: callId, status: 'pending', title: invocation.getDescription(), content, locations: invocation.toolLocations(), kind: toAcpToolKind(tool.kind), }, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = await this.connection.requestPermission(params); const outcome = output.outcome.outcome === CoreToolCallStatus.Cancelled ? ToolConfirmationOutcome.Cancel : z .nativeEnum(ToolConfirmationOutcome) .parse(output.outcome.optionId); await confirmationDetails.onConfirm(outcome); switch (outcome) { case ToolConfirmationOutcome.Cancel: return errorResponse( new Error(`Tool "${fc.name}" was canceled by the user.`), ); case ToolConfirmationOutcome.ProceedOnce: case ToolConfirmationOutcome.ProceedAlways: case ToolConfirmationOutcome.ProceedAlwaysAndSave: case ToolConfirmationOutcome.ProceedAlwaysServer: case ToolConfirmationOutcome.ProceedAlwaysTool: case ToolConfirmationOutcome.ModifyWithEditor: break; default: { const resultOutcome: never = outcome; throw new Error(`Unexpected: ${resultOutcome}`); } } } else { await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: callId, status: 'in_progress', title: invocation.getDescription(), content: [], locations: invocation.toolLocations(), kind: toAcpToolKind(tool.kind), }); } const toolResult: ToolResult = await invocation.execute(abortSignal); const content = toToolCallContent(toolResult); await this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', title: invocation.getDescription(), content: content ? [content] : [], locations: invocation.toolLocations(), kind: toAcpToolKind(tool.kind), }); const durationMs = Date.now() - startTime; logToolCall( this.context.config, new ToolCallEvent( undefined, fc.name ?? '', args, durationMs, true, promptId, typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool ? 'mcp' : 'native', ), ); this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Success, request: { callId, name: fc.name, args, isClientInitiated: false, prompt_id: promptId, }, tool, invocation, response: { callId, responseParts: convertToFunctionResponse( fc.name, callId, toolResult.llmContent, this.context.config.getActiveModel(), this.context.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, errorType: undefined, }, }, ]); return convertToFunctionResponse( fc.name, callId, toolResult.llmContent, this.context.config.getActiveModel(), this.context.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); await this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'failed', content: [ { type: 'content', content: { type: 'text', text: error.message } }, ], kind: toAcpToolKind(tool.kind), }); this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Error, request: { callId, name: fc.name, args, isClientInitiated: false, prompt_id: promptId, }, tool, response: { callId, responseParts: [ { functionResponse: { id: callId, name: fc.name ?? '', response: { error: error.message }, }, }, ], resultDisplay: error.message, error, errorType: undefined, }, }, ]); return errorResponse(error); } } async #resolvePrompt( message: acp.ContentBlock[], abortSignal: AbortSignal, ): Promise { const FILE_URI_SCHEME = 'file://'; const embeddedContext: acp.EmbeddedResourceResource[] = []; const parts = message.map((part) => { switch (part.type) { case 'text': return { text: part.text }; case 'image': case 'audio': return { inlineData: { mimeType: part.mimeType, data: part.data, }, }; case 'resource_link': { if (part.uri.startsWith(FILE_URI_SCHEME)) { return { fileData: { mimeData: part.mimeType, name: part.name, fileUri: part.uri.slice(FILE_URI_SCHEME.length), }, }; } else { return { text: `@${part.uri}` }; } } case 'resource': { embeddedContext.push(part.resource); return { text: `@${part.resource.uri}` }; } default: { const unreachable: never = part; throw new Error(`Unexpected chunk type: '${unreachable}'`); } } }); const atPathCommandParts = parts.filter((part) => 'fileData' in part); if (atPathCommandParts.length === 0 && embeddedContext.length === 0) { return parts; } const atPathToResolvedSpecMap = new Map(); // Get centralized file discovery service const fileDiscovery = this.context.config.getFileService(); const fileFilteringOptions: FilterFilesOptions = this.context.config.getFileFilteringOptions(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; const toolRegistry = this.context.toolRegistry; const readManyFilesTool = new ReadManyFilesTool( this.context.config, this.context.messageBus, ); const globTool = toolRegistry.getTool('glob'); if (!readManyFilesTool) { throw new Error('Error: read_many_files tool not found.'); } for (const atPathPart of atPathCommandParts) { const pathName = atPathPart.fileData!.fileUri; // Check if path should be ignored if (fileDiscovery.shouldIgnoreFile(pathName, fileFilteringOptions)) { ignoredPaths.push(pathName); debugLogger.warn(`Path ${pathName} is ignored and will be skipped.`); continue; } let currentPathSpec = pathName; let resolvedSuccessfully = false; try { const absolutePath = path.resolve( this.context.config.getTargetDir(), pathName, ); if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { currentPathSpec = pathName.endsWith('/') ? `${pathName}**` : `${pathName}/**`; this.debug( `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, ); } else { this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`); } resolvedSuccessfully = true; } else { this.debug( `Path ${pathName} is outside the project directory. Skipping.`, ); } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { if (this.context.config.getEnableRecursiveFileSearch() && globTool) { this.debug( `Path ${pathName} not found directly, attempting glob search.`, ); try { const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, path: this.context.config.getTargetDir(), }, abortSignal, ); if ( globResult.llmContent && typeof globResult.llmContent === 'string' && !globResult.llmContent.startsWith('No files found') && !globResult.llmContent.startsWith('Error:') ) { const lines = globResult.llmContent.split('\n'); if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); currentPathSpec = path.relative( this.context.config.getTargetDir(), firstMatchAbsolute, ); this.debug( `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, ); resolvedSuccessfully = true; } else { this.debug( `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, ); } } else { this.debug( `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, ); } } catch (globError) { debugLogger.error( `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, ); } } else { this.debug( `Glob tool not found. Path ${pathName} will be skipped.`, ); } } else { debugLogger.error( `Error stating path ${pathName}. Path ${pathName} will be skipped.`, ); } } if (resolvedSuccessfully) { pathSpecsToRead.push(currentPathSpec); atPathToResolvedSpecMap.set(pathName, currentPathSpec); contentLabelsForDisplay.push(pathName); } } // Construct the initial part of the query for the LLM let initialQueryText = ''; for (let i = 0; i < parts.length; i++) { const chunk = parts[i]; if ('text' in chunk) { initialQueryText += chunk.text; } else { // type === 'atPath' const resolvedSpec = chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri); if ( i > 0 && initialQueryText.length > 0 && !initialQueryText.endsWith(' ') && resolvedSpec ) { // Add space if previous part was text and didn't end with space, or if previous was @path const prevPart = parts[i - 1]; if ( 'text' in prevPart || ('fileData' in prevPart && atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri)) ) { initialQueryText += ' '; } } if (resolvedSpec) { initialQueryText += `@${resolvedSpec}`; } else { // If not resolved for reading (e.g. lone @ or invalid path that was skipped), // add the original @-string back, ensuring spacing if it's not the first element. if ( i > 0 && initialQueryText.length > 0 && !initialQueryText.endsWith(' ') && !chunk.fileData?.fileUri.startsWith(' ') ) { initialQueryText += ' '; } if (chunk.fileData?.fileUri) { initialQueryText += `@${chunk.fileData.fileUri}`; } } } } initialQueryText = initialQueryText.trim(); // Inform user about ignored paths if (ignoredPaths.length > 0) { this.debug( `Ignored ${ignoredPaths.length} files: ${ignoredPaths.join(', ')}`, ); } const processedQueryParts: Part[] = [{ text: initialQueryText }]; if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) { // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText debugLogger.warn('No valid file paths found in @ commands to read.'); return [{ text: initialQueryText }]; } if (pathSpecsToRead.length > 0) { const toolArgs = { include: pathSpecsToRead, }; const callId = `${readManyFilesTool.name}-${Date.now()}`; try { const invocation = readManyFilesTool.build(toolArgs); await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: callId, status: 'in_progress', title: invocation.getDescription(), content: [], locations: invocation.toolLocations(), kind: toAcpToolKind(readManyFilesTool.kind), }); const result = await invocation.execute(abortSignal); const content = toToolCallContent(result) || { type: 'content', content: { type: 'text', text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, }, }; await this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', title: invocation.getDescription(), content: content ? [content] : [], locations: invocation.toolLocations(), kind: toAcpToolKind(readManyFilesTool.kind), }); if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; processedQueryParts.push({ text: `\n${REFERENCE_CONTENT_START}`, }); for (const part of result.llmContent) { if (typeof part === 'string') { const match = fileContentRegex.exec(part); if (match) { const filePathSpecInContent = match[1]; // This is a resolved pathSpec const fileActualContent = match[2].trim(); processedQueryParts.push({ text: `\nContent from @${filePathSpecInContent}:\n`, }); processedQueryParts.push({ text: fileActualContent }); } else { processedQueryParts.push({ text: part }); } } else { // part is a Part object. processedQueryParts.push(part); } } } else { debugLogger.warn( 'read_many_files tool returned no content or empty content.', ); } } catch (error: unknown) { await this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'failed', content: [ { type: 'content', content: { type: 'text', text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, }, }, ], kind: toAcpToolKind(readManyFilesTool.kind), }); throw error; } } if (embeddedContext.length > 0) { processedQueryParts.push({ text: '\n--- Content from referenced context ---', }); for (const contextPart of embeddedContext) { processedQueryParts.push({ text: `\nContent from @${contextPart.uri}:\n`, }); if ('text' in contextPart) { processedQueryParts.push({ text: contextPart.text, }); } else { processedQueryParts.push({ inlineData: { mimeType: contextPart.mimeType ?? 'application/octet-stream', data: contextPart.blob, }, }); } } } return processedQueryParts; } debug(msg: string) { if (this.context.config.getDebugMode()) { debugLogger.warn(msg); } } } function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { if (toolResult.error?.message) { throw new Error(toolResult.error.message); } if (toolResult.returnDisplay) { if (typeof toolResult.returnDisplay === 'string') { return { type: 'content', content: { type: 'text', text: toolResult.returnDisplay }, }; } else { if ('fileName' in toolResult.returnDisplay) { return { type: 'diff', path: toolResult.returnDisplay.filePath ?? toolResult.returnDisplay.fileName, oldText: toolResult.returnDisplay.originalContent, newText: toolResult.returnDisplay.newContent, _meta: { kind: !toolResult.returnDisplay.originalContent ? 'add' : toolResult.returnDisplay.newContent === '' ? 'delete' : 'modify', }, }; } return null; } } else { return null; } } const basicPermissionOptions = [ { optionId: ToolConfirmationOutcome.ProceedOnce, name: 'Allow', kind: 'allow_once', }, { optionId: ToolConfirmationOutcome.Cancel, name: 'Reject', kind: 'reject_once', }, ] as const; function toPermissionOptions( confirmation: ToolCallConfirmationDetails, config: Config, ): acp.PermissionOption[] { const disableAlwaysAllow = config.getDisableAlwaysAllow(); const options: acp.PermissionOption[] = []; if (!disableAlwaysAllow) { switch (confirmation.type) { case 'edit': options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: 'Allow All Edits', kind: 'allow_always', }); break; case 'exec': options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow ${confirmation.rootCommand}`, kind: 'allow_always', }); break; case 'mcp': options.push( { optionId: ToolConfirmationOutcome.ProceedAlwaysServer, name: `Always Allow ${confirmation.serverName}`, kind: 'allow_always', }, { optionId: ToolConfirmationOutcome.ProceedAlwaysTool, name: `Always Allow ${confirmation.toolName}`, kind: 'allow_always', }, ); break; case 'info': options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow`, kind: 'allow_always', }); break; case 'ask_user': case 'exit_plan_mode': // askuser and exit_plan_mode don't need "always allow" options break; default: // No "always allow" options for other types break; } } options.push(...basicPermissionOptions); // Exhaustive check switch (confirmation.type) { case 'edit': case 'exec': case 'mcp': case 'info': case 'ask_user': case 'exit_plan_mode': break; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); } } return options; } /** * Maps our internal tool kind to the ACP ToolKind. * Fallback to 'other' for kinds that are not supported by the ACP protocol. */ function toAcpToolKind(kind: Kind): acp.ToolKind { switch (kind) { case Kind.Read: case Kind.Edit: case Kind.Execute: case Kind.Search: case Kind.Delete: case Kind.Move: case Kind.Think: case Kind.Fetch: case Kind.SwitchMode: case Kind.Other: return kind as acp.ToolKind; case Kind.Agent: return 'think'; case Kind.Plan: case Kind.Communicate: default: return 'other'; } } function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] { const modes: acp.SessionMode[] = [ { id: ApprovalMode.DEFAULT, name: 'Default', description: 'Prompts for approval', }, { id: ApprovalMode.AUTO_EDIT, name: 'Auto Edit', description: 'Auto-approves edit tools', }, { id: ApprovalMode.YOLO, name: 'YOLO', description: 'Auto-approves all tools', }, ]; if (isPlanEnabled) { modes.push({ id: ApprovalMode.PLAN, name: 'Plan', description: 'Read-only mode', }); } return modes; } function buildAvailableModels( config: Config, settings: LoadedSettings, ): { availableModels: Array<{ modelId: string; name: string; description?: string; }>; currentModelId: string; } { const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; const selectedAuthType = settings.merged.security.auth.selectedType; const useCustomToolModel = useGemini31 && selectedAuthType === AuthType.USE_GEMINI; const mainOptions = [ { value: DEFAULT_GEMINI_MODEL_AUTO, title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO), description: 'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash', }, ]; if (shouldShowPreviewModels) { mainOptions.unshift({ value: PREVIEW_GEMINI_MODEL_AUTO, title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), description: useGemini31 ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash' : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', }); } const manualOptions = [ { value: DEFAULT_GEMINI_MODEL, title: getDisplayString(DEFAULT_GEMINI_MODEL), }, { value: DEFAULT_GEMINI_FLASH_MODEL, title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL), }, { value: DEFAULT_GEMINI_FLASH_LITE_MODEL, title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL), }, ]; if (shouldShowPreviewModels) { const previewProModel = useGemini31 ? PREVIEW_GEMINI_3_1_MODEL : PREVIEW_GEMINI_MODEL; const previewProValue = useCustomToolModel ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL : previewProModel; manualOptions.unshift( { value: previewProValue, title: getDisplayString(previewProModel), }, { value: PREVIEW_GEMINI_FLASH_MODEL, title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), }, ); } const scaleOptions = ( options: Array<{ value: string; title: string; description?: string }>, ) => options.map((o) => ({ modelId: o.value, name: o.title, description: o.description, })); return { availableModels: [ ...scaleOptions(mainOptions), ...scaleOptions(manualOptions), ], currentModelId: preferredModel, }; } ================================================ FILE: packages/cli/src/acp/acpErrors.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { getAcpErrorMessage } from './acpErrors.js'; describe('getAcpErrorMessage', () => { it('should return plain error message', () => { expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error'); }); it('should parse simple JSON error response', () => { const json = JSON.stringify({ error: { message: 'json error' } }); expect(getAcpErrorMessage(new Error(json))).toBe('json error'); }); it('should parse double-encoded JSON error response', () => { const innerJson = JSON.stringify({ error: { message: 'nested error' } }); const outerJson = JSON.stringify({ error: { message: innerJson } }); expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error'); }); it('should parse array-style JSON error response', () => { const json = JSON.stringify([{ error: { message: 'array error' } }]); expect(getAcpErrorMessage(new Error(json))).toBe('array error'); }); it('should parse JSON with top-level message field', () => { const json = JSON.stringify({ message: 'top-level message' }); expect(getAcpErrorMessage(new Error(json))).toBe('top-level message'); }); it('should handle JSON with trailing newline', () => { const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n'; expect(getAcpErrorMessage(new Error(json))).toBe('newline error'); }); it('should return original message if JSON parsing fails', () => { const invalidJson = '{ not-json }'; expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson); }); }); ================================================ FILE: packages/cli/src/acp/acpErrors.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core'; /** * Extracts a human-readable error message specifically for ACP (IDE) clients. * This function recursively parses JSON error blobs that are common in * Google API responses but ugly to display in an IDE's UI. */ export function getAcpErrorMessage(error: unknown): string { const coreMessage = getCoreErrorMessage(error); return extractRecursiveMessage(coreMessage); } function extractRecursiveMessage(input: string): string { const trimmed = input.trim(); // Attempt to parse JSON error responses (common in Google API errors) if ( (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) ) { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(trimmed); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const next = parsed?.error?.message || parsed?.[0]?.error?.message || parsed?.message; if (next && typeof next === 'string' && next !== input) { return extractRecursiveMessage(next); } } catch { // Fall back to original string if parsing fails } } return input; } ================================================ FILE: packages/cli/src/acp/acpResume.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mocked, type Mock, } from 'vitest'; import { GeminiAgent } from './acpClient.js'; import * as acp from '@agentclientprotocol/sdk'; import { ApprovalMode, AuthType, type Config, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { loadCliConfig, type CliArgs } from '../config/config.js'; import { SessionSelector, convertSessionToHistoryFormats, } from '../utils/sessionUtils.js'; import { convertSessionToClientHistory } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../config/settings.js'; vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), })); vi.mock('../utils/sessionUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, SessionSelector: vi.fn(), convertSessionToHistoryFormats: vi.fn(), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, CoreToolCallStatus: { Validating: 'validating', Scheduled: 'scheduled', Error: 'error', Success: 'success', Executing: 'executing', Cancelled: 'cancelled', AwaitingApproval: 'awaiting_approval', }, LlmRole: { MAIN: 'main', SUBAGENT: 'subagent', UTILITY_TOOL: 'utility_tool', USER: 'user', MODEL: 'model', SYSTEM: 'system', TOOL: 'tool', }, convertSessionToClientHistory: vi.fn(), }; }); describe('GeminiAgent Session Resume', () => { let mockConfig: Mocked; let mockSettings: Mocked; let mockArgv: CliArgs; let mockConnection: Mocked; let agent: GeminiAgent; beforeEach(() => { mockConfig = { refreshAuth: vi.fn().mockResolvedValue(undefined), initialize: vi.fn().mockResolvedValue(undefined), getFileSystemService: vi.fn(), setFileSystemService: vi.fn(), getGeminiClient: vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), resumeChat: vi.fn().mockResolvedValue(undefined), getChat: vi.fn().mockReturnValue({}), }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, getApprovalMode: vi.fn().mockReturnValue('default'), isPlanEnabled: vi.fn().mockReturnValue(true), getModel: vi.fn().mockReturnValue('gemini-pro'), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), get config() { return this; }, } as unknown as Mocked; mockSettings = { merged: { security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, mcpServers: {}, }, setValue: vi.fn(), } as unknown as Mocked; mockArgv = {} as unknown as CliArgs; mockConnection = { sessionUpdate: vi.fn().mockResolvedValue(undefined), } as unknown as Mocked; (loadCliConfig as Mock).mockResolvedValue(mockConfig); agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); }); it('should advertise loadSession capability', async () => { const response = await agent.initialize({ protocolVersion: acp.PROTOCOL_VERSION, }); expect(response.agentCapabilities?.loadSession).toBe(true); }); it('should load a session, resume chat, and stream all message types', async () => { const sessionId = 'existing-session-id'; const sessionData = { sessionId, messages: [ { type: 'user', content: [{ text: 'Hello' }] }, { type: 'gemini', content: [{ text: 'Hi there' }], thoughts: [{ subject: 'Thinking', description: 'about greeting' }], toolCalls: [ { id: 'call-1', name: 'test_tool', displayName: 'Test Tool', status: CoreToolCallStatus.Success, resultDisplay: 'Tool output', }, ], }, { type: 'gemini', content: [{ text: 'Trying a write' }], toolCalls: [ { id: 'call-2', name: 'write_file', displayName: 'Write File', status: CoreToolCallStatus.Error, resultDisplay: 'Permission denied', }, ], }, ], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockConfig as any).toolRegistry = { getTool: vi.fn().mockReturnValue({ kind: 'read' }), }; (SessionSelector as unknown as Mock).mockImplementation(() => ({ resolveSession: vi.fn().mockResolvedValue({ sessionData, sessionPath: '/path/to/session.json', }), })); const mockClientHistory = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there' }] }, ]; (convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({ uiHistory: [], }); (convertSessionToClientHistory as unknown as Mock).mockReturnValue( mockClientHistory, ); const response = await agent.loadSession({ sessionId, cwd: '/tmp', mcpServers: [], }); expect(response).toEqual({ modes: { availableModes: [ { id: ApprovalMode.DEFAULT, name: 'Default', description: 'Prompts for approval', }, { id: ApprovalMode.AUTO_EDIT, name: 'Auto Edit', description: 'Auto-approves edit tools', }, { id: ApprovalMode.YOLO, name: 'YOLO', description: 'Auto-approves all tools', }, { id: ApprovalMode.PLAN, name: 'Plan', description: 'Read-only mode', }, ], currentModeId: ApprovalMode.DEFAULT, }, models: { availableModels: expect.any(Array) as unknown, currentModelId: 'gemini-pro', }, }); // Verify resumeChat received the correct arguments expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith( mockClientHistory, expect.objectContaining({ conversation: sessionData, filePath: '/path/to/session.json', }), ); await vi.waitFor(() => { // User message expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'user_message_chunk', content: expect.objectContaining({ text: 'Hello' }), }), }), ); // Agent thought expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'agent_thought_chunk', content: expect.objectContaining({ text: '**Thinking**\nabout greeting', }), }), }), ); // Agent message expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'agent_message_chunk', content: expect.objectContaining({ text: 'Hi there' }), }), }), ); // Successful tool call → 'completed' expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call', toolCallId: 'call-1', status: 'completed', title: 'Test Tool', kind: 'read', content: [ { type: 'content', content: { type: 'text', text: 'Tool output' }, }, ], }), }), ); // Failed tool call → 'failed' expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ sessionUpdate: 'tool_call', toolCallId: 'call-2', status: 'failed', title: 'Write File', kind: 'read', }), }), ); }); }); }); ================================================ FILE: packages/cli/src/acp/commandHandler.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandHandler } from './commandHandler.js'; import { describe, it, expect } from 'vitest'; describe('CommandHandler', () => { it('parses commands correctly', () => { const handler = new CommandHandler(); // @ts-expect-error - testing private method const parse = (query: string) => handler.parseSlashCommand(query); const memShow = parse('/memory show'); expect(memShow.commandToExecute?.name).toBe('memory show'); expect(memShow.args).toBe(''); const memAdd = parse('/memory add hello world'); expect(memAdd.commandToExecute?.name).toBe('memory add'); expect(memAdd.args).toBe('hello world'); const extList = parse('/extensions list'); expect(extList.commandToExecute?.name).toBe('extensions list'); const init = parse('/init'); expect(init.commandToExecute?.name).toBe('init'); }); }); ================================================ FILE: packages/cli/src/acp/commandHandler.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Command, CommandContext } from './commands/types.js'; import { CommandRegistry } from './commands/commandRegistry.js'; import { MemoryCommand } from './commands/memory.js'; import { ExtensionsCommand } from './commands/extensions.js'; import { InitCommand } from './commands/init.js'; import { RestoreCommand } from './commands/restore.js'; export class CommandHandler { private registry: CommandRegistry; constructor() { this.registry = CommandHandler.createRegistry(); } private static createRegistry(): CommandRegistry { const registry = new CommandRegistry(); registry.register(new MemoryCommand()); registry.register(new ExtensionsCommand()); registry.register(new InitCommand()); registry.register(new RestoreCommand()); return registry; } getAvailableCommands(): Array<{ name: string; description: string }> { return this.registry.getAllCommands().map((cmd) => ({ name: cmd.name, description: cmd.description, })); } /** * Parses and executes a command string if it matches a registered command. * Returns true if a command was handled, false otherwise. */ async handleCommand( commandText: string, context: CommandContext, ): Promise { const { commandToExecute, args } = this.parseSlashCommand(commandText); if (commandToExecute) { await this.runCommand(commandToExecute, args, context); return true; } return false; } private async runCommand( commandToExecute: Command, args: string, context: CommandContext, ): Promise { try { const result = await commandToExecute.execute( context, args ? args.split(/\s+/) : [], ); let messageContent = ''; if (typeof result.data === 'string') { messageContent = result.data; } else if ( typeof result.data === 'object' && result.data !== null && 'content' in result.data ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any messageContent = (result.data as Record)[ 'content' ] as string; } else { messageContent = JSON.stringify(result.data, null, 2); } await context.sendMessage(messageContent); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); await context.sendMessage(`Error: ${errorMessage}`); } } /** * Parses a raw slash command string into its matching headless command and arguments. * Mirrors `packages/cli/src/utils/commands.ts` logic. */ private parseSlashCommand(query: string): { commandToExecute: Command | undefined; args: string; } { const trimmed = query.trim(); const parts = trimmed.substring(1).trim().split(/\s+/); const commandPath = parts.filter((p) => p); let currentCommands = this.registry.getAllCommands(); let commandToExecute: Command | undefined; let pathIndex = 0; for (const part of commandPath) { const foundCommand = currentCommands.find((cmd) => { const expectedName = commandPath.slice(0, pathIndex + 1).join(' '); return ( cmd.name === part || cmd.name === expectedName || cmd.aliases?.includes(part) || cmd.aliases?.includes(expectedName) ); }); if (foundCommand) { commandToExecute = foundCommand; pathIndex++; if (foundCommand.subCommands) { currentCommands = foundCommand.subCommands; } else { break; } } else { break; } } const args = parts.slice(pathIndex).join(' '); return { commandToExecute, args }; } } ================================================ FILE: packages/cli/src/acp/commands/commandRegistry.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger } from '@google/gemini-cli-core'; import type { Command } from './types.js'; export class CommandRegistry { private readonly commands = new Map(); register(command: Command) { if (this.commands.has(command.name)) { debugLogger.warn(`Command ${command.name} already registered. Skipping.`); return; } this.commands.set(command.name, command); for (const subCommand of command.subCommands ?? []) { this.register(subCommand); } } get(commandName: string): Command | undefined { return this.commands.get(commandName); } getAllCommands(): Command[] { return [...this.commands.values()]; } } ================================================ FILE: packages/cli/src/acp/commands/extensions.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { listExtensions, type Config, getErrorMessage, } from '@google/gemini-cli-core'; import { SettingScope } from '../../config/settings.js'; import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; import { stat } from 'node:fs/promises'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; export class ExtensionsCommand implements Command { readonly name = 'extensions'; readonly description = 'Manage extensions.'; readonly subCommands = [ new ListExtensionsCommand(), new ExploreExtensionsCommand(), new EnableExtensionCommand(), new DisableExtensionCommand(), new InstallExtensionCommand(), new LinkExtensionCommand(), new UninstallExtensionCommand(), new RestartExtensionCommand(), new UpdateExtensionCommand(), ]; async execute( context: CommandContext, _: string[], ): Promise { return new ListExtensionsCommand().execute(context, _); } } export class ListExtensionsCommand implements Command { readonly name = 'extensions list'; readonly description = 'Lists all installed extensions.'; async execute( context: CommandContext, _: string[], ): Promise { const extensions = listExtensions(context.agentContext.config); const data = extensions.length ? extensions : 'No extensions installed.'; return { name: this.name, data }; } } export class ExploreExtensionsCommand implements Command { readonly name = 'extensions explore'; readonly description = 'Explore available extensions.'; async execute( _context: CommandContext, _: string[], ): Promise { const extensionsUrl = 'https://geminicli.com/extensions/'; return { name: this.name, data: `View or install available extensions at ${extensionsUrl}`, }; } } function getEnableDisableContext( config: Config, args: string[], invocationName: string, ) { const extensionManager = config.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { return { error: `Cannot ${invocationName} extensions in this environment.`, }; } if (args.length === 0) { return { error: `Usage: /extensions ${invocationName} [--scope=]`, }; } let scope = SettingScope.User; if (args.includes('--scope=workspace') || args.includes('workspace')) { scope = SettingScope.Workspace; } else if (args.includes('--scope=session') || args.includes('session')) { scope = SettingScope.Session; } const name = args.filter( (a) => !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a), )[0]; let names: string[] = []; if (name === '--all') { let extensions = extensionManager.getExtensions(); if (invocationName === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); } if (invocationName === 'disable') { extensions = extensions.filter((ext) => ext.isActive); } names = extensions.map((ext) => ext.name); } else if (name) { names = [name]; } else { return { error: 'No extension name provided.' }; } return { extensionManager, names, scope }; } export class EnableExtensionCommand implements Command { readonly name = 'extensions enable'; readonly description = 'Enable an extension.'; async execute( context: CommandContext, args: string[], ): Promise { const enableContext = getEnableDisableContext( context.agentContext.config, args, 'enable', ); if ('error' in enableContext) { return { name: this.name, data: enableContext.error }; } const { names, scope, extensionManager } = enableContext; const output: string[] = []; for (const name of names) { try { await extensionManager.enableExtension(name, scope); output.push(`Extension "${name}" enabled for scope "${scope}".`); const extension = extensionManager .getExtensions() .find((e) => e.name === name); if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); const mcpClientManager = context.agentContext.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers), ); if (mcpClientManager && enabledServers.length > 0) { const restartPromises = enabledServers.map((serverName) => mcpClientManager.restartServer(serverName).catch((error) => { output.push( `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, ); }), ); await Promise.all(restartPromises); output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`); } } } catch (e) { output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`); } } return { name: this.name, data: output.join('\n') || 'No action taken.' }; } } export class DisableExtensionCommand implements Command { readonly name = 'extensions disable'; readonly description = 'Disable an extension.'; async execute( context: CommandContext, args: string[], ): Promise { const enableContext = getEnableDisableContext( context.agentContext.config, args, 'disable', ); if ('error' in enableContext) { return { name: this.name, data: enableContext.error }; } const { names, scope, extensionManager } = enableContext; const output: string[] = []; for (const name of names) { try { await extensionManager.disableExtension(name, scope); output.push(`Extension "${name}" disabled for scope "${scope}".`); } catch (e) { output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`); } } return { name: this.name, data: output.join('\n') || 'No action taken.' }; } } export class InstallExtensionCommand implements Command { readonly name = 'extensions install'; readonly description = 'Install an extension from a git repo or local path.'; async execute( context: CommandContext, args: string[], ): Promise { const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot install extensions in this environment.', }; } const source = args.join(' ').trim(); if (!source) { return { name: this.name, data: `Usage: /extensions install ` }; } if (/[;&|`'"]/.test(source)) { return { name: this.name, data: `Invalid source: contains disallowed characters.`, }; } try { const installMetadata = await inferInstallMetadata(source); const extension = await extensionLoader.installOrUpdateExtension(installMetadata); return { name: this.name, data: `Extension "${extension.name}" installed successfully.`, }; } catch (error) { return { name: this.name, data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`, }; } } } export class LinkExtensionCommand implements Command { readonly name = 'extensions link'; readonly description = 'Link an extension from a local path.'; async execute( context: CommandContext, args: string[], ): Promise { const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot link extensions in this environment.', }; } const sourceFilepath = args.join(' ').trim(); if (!sourceFilepath) { return { name: this.name, data: `Usage: /extensions link ` }; } try { await stat(sourceFilepath); } catch (_error) { return { name: this.name, data: `Invalid source: ${sourceFilepath}` }; } try { const extension = await extensionLoader.installOrUpdateExtension({ source: sourceFilepath, type: 'link', }); return { name: this.name, data: `Extension "${extension.name}" linked successfully.`, }; } catch (error) { return { name: this.name, data: `Failed to link extension: ${getErrorMessage(error)}`, }; } } } export class UninstallExtensionCommand implements Command { readonly name = 'extensions uninstall'; readonly description = 'Uninstall an extension.'; async execute( context: CommandContext, args: string[], ): Promise { const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot uninstall extensions in this environment.', }; } const all = args.includes('--all'); const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim()); if (!all && names.length === 0) { return { name: this.name, data: `Usage: /extensions uninstall |--all`, }; } let namesToUninstall: string[] = []; if (all) { namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); } else { namesToUninstall = names; } if (namesToUninstall.length === 0) { return { name: this.name, data: all ? 'No extensions installed.' : 'No extension name provided.', }; } const output: string[] = []; for (const extensionName of namesToUninstall) { try { await extensionLoader.uninstallExtension(extensionName, false); output.push(`Extension "${extensionName}" uninstalled successfully.`); } catch (error) { output.push( `Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`, ); } } return { name: this.name, data: output.join('\n') }; } } export class RestartExtensionCommand implements Command { readonly name = 'extensions restart'; readonly description = 'Restart an extension.'; async execute( context: CommandContext, args: string[], ): Promise { const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot restart extensions.' }; } const all = args.includes('--all'); const names = all ? null : args.filter((a) => !!a); if (!all && names?.length === 0) { return { name: this.name, data: 'Usage: /extensions restart |--all', }; } let extensionsToRestart = extensionLoader .getExtensions() .filter((e) => e.isActive); if (names) { extensionsToRestart = extensionsToRestart.filter((e) => names.includes(e.name), ); } if (extensionsToRestart.length === 0) { return { name: this.name, data: 'No active extensions matched the request.', }; } const output: string[] = []; for (const extension of extensionsToRestart) { try { await extensionLoader.restartExtension(extension); output.push(`Restarted "${extension.name}".`); } catch (e) { output.push( `Failed to restart "${extension.name}": ${getErrorMessage(e)}`, ); } } return { name: this.name, data: output.join('\n') }; } } export class UpdateExtensionCommand implements Command { readonly name = 'extensions update'; readonly description = 'Update an extension.'; async execute( context: CommandContext, args: string[], ): Promise { const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot update extensions.' }; } const all = args.includes('--all'); const names = all ? null : args.filter((a) => !!a); if (!all && names?.length === 0) { return { name: this.name, data: 'Usage: /extensions update |--all', }; } return { name: this.name, data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.', }; } } ================================================ FILE: packages/cli/src/acp/commands/init.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { performInit } from '@google/gemini-cli-core'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; export class InitCommand implements Command { name = 'init'; description = 'Analyzes the project and creates a tailored GEMINI.md file'; requiresWorkspace = true; async execute( context: CommandContext, _args: string[] = [], ): Promise { const targetDir = context.agentContext.config.getTargetDir(); if (!targetDir) { throw new Error('Command requires a workspace.'); } const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); switch (result.type) { case 'message': return { name: this.name, data: result, }; case 'submit_prompt': fs.writeFileSync(geminiMdPath, '', 'utf8'); if (typeof result.content !== 'string') { throw new Error('Init command content must be a string.'); } // Inform the user since we can't trigger the UI-based interactive agent loop here directly. // We output the prompt text they can use to re-trigger the generation manually, // or just seed the GEMINI.md file as we've done above. return { name: this.name, data: { type: 'message', messageType: 'info', content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`, }, }; default: throw new Error('Unknown result type from performInit'); } } } ================================================ FILE: packages/cli/src/acp/commands/memory.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { addMemory, listMemoryFiles, refreshMemory, showMemory, } from '@google/gemini-cli-core'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; const DEFAULT_SANITIZATION_CONFIG = { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }; export class MemoryCommand implements Command { readonly name = 'memory'; readonly description = 'Manage memory.'; readonly subCommands = [ new ShowMemoryCommand(), new RefreshMemoryCommand(), new ListMemoryCommand(), new AddMemoryCommand(), ]; readonly requiresWorkspace = true; async execute( context: CommandContext, _: string[], ): Promise { return new ShowMemoryCommand().execute(context, _); } } export class ShowMemoryCommand implements Command { readonly name = 'memory show'; readonly description = 'Shows the current memory contents.'; async execute( context: CommandContext, _: string[], ): Promise { const result = showMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } export class RefreshMemoryCommand implements Command { readonly name = 'memory refresh'; readonly aliases = ['memory reload']; readonly description = 'Refreshes the memory from the source.'; async execute( context: CommandContext, _: string[], ): Promise { const result = await refreshMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } export class ListMemoryCommand implements Command { readonly name = 'memory list'; readonly description = 'Lists the paths of the GEMINI.md files in use.'; async execute( context: CommandContext, _: string[], ): Promise { const result = listMemoryFiles(context.agentContext.config); return { name: this.name, data: result.content }; } } export class AddMemoryCommand implements Command { readonly name = 'memory add'; readonly description = 'Add content to the memory.'; async execute( context: CommandContext, args: string[], ): Promise { const textToAdd = args.join(' ').trim(); const result = addMemory(textToAdd); if (result.type === 'message') { return { name: this.name, data: result.content }; } const toolRegistry = context.agentContext.toolRegistry; const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); const signal = abortController.signal; await context.sendMessage(`Saving memory via ${result.toolName}...`); await tool.buildAndExecute(result.toolArgs, signal, undefined, { shellExecutionConfig: { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, sandboxManager: context.agentContext.sandboxManager, }, }); await refreshMemory(context.agentContext.config); return { name: this.name, data: `Added memory: "${textToAdd}"`, }; } else { return { name: this.name, data: `Error: Tool ${result.toolName} not found.`, }; } } } ================================================ FILE: packages/cli/src/acp/commands/restore.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getCheckpointInfoList, getToolCallDataSchema, isNodeError, performRestore, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { Command, CommandContext, CommandExecutionResponse, } from './types.js'; export class RestoreCommand implements Command { readonly name = 'restore'; readonly description = 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created'; readonly requiresWorkspace = true; readonly subCommands = [new ListCheckpointsCommand()]; async execute( context: CommandContext, args: string[], ): Promise { const { agentContext: agentContext, git: gitService } = context; const { config } = agentContext; const argsStr = args.join(' '); try { if (!argsStr) { return await new ListCheckpointsCommand().execute(context); } if (!config.getCheckpointingEnabled()) { return { name: this.name, data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', }; } const selectedFile = argsStr.endsWith('.json') ? argsStr : `${argsStr}.json`; const checkpointDir = config.storage.getProjectTempCheckpointsDir(); const filePath = path.join(checkpointDir, selectedFile); let data: string; try { data = await fs.readFile(filePath, 'utf-8'); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { return { name: this.name, data: `File not found: ${selectedFile}`, }; } throw error; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const toolCallData = JSON.parse(data); const ToolCallDataSchema = getToolCallDataSchema(); const parseResult = ToolCallDataSchema.safeParse(toolCallData); if (!parseResult.success) { return { name: this.name, data: 'Checkpoint file is invalid or corrupted.', }; } const restoreResultGenerator = performRestore( parseResult.data, gitService, ); const restoreResult = []; for await (const result of restoreResultGenerator) { restoreResult.push(result); } // Format the result nicely since Zed just dumps data const formattedResult = restoreResult .map((r) => { if (r.type === 'message') { return `[${r.messageType.toUpperCase()}] ${r.content}`; } else if (r.type === 'load_history') { return `Loaded history with ${r.clientHistory.length} messages.`; } return `Restored: ${JSON.stringify(r)}`; }) .join('\n'); return { name: this.name, data: formattedResult, }; } catch (error) { return { name: this.name, data: `An unexpected error occurred during restore: ${error}`, }; } } } export class ListCheckpointsCommand implements Command { readonly name = 'restore list'; readonly description = 'Lists all available checkpoints.'; async execute(context: CommandContext): Promise { const { config } = context.agentContext; try { if (!config.getCheckpointingEnabled()) { return { name: this.name, data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', }; } const checkpointDir = config.storage.getProjectTempCheckpointsDir(); try { await fs.mkdir(checkpointDir, { recursive: true }); } catch (_e) { // Ignore } const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); if (jsonFiles.length === 0) { return { name: this.name, data: 'No checkpoints found.' }; } const checkpointFiles = new Map(); for (const file of jsonFiles) { const filePath = path.join(checkpointDir, file); const data = await fs.readFile(filePath, 'utf-8'); checkpointFiles.set(file, data); } const checkpointInfoList = getCheckpointInfoList(checkpointFiles); const formatted = checkpointInfoList .map((info) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const i = info as Record; const fileName = String(i['fileName'] || 'Unknown'); const toolName = String(i['toolName'] || 'Unknown'); const status = String(i['status'] || 'Unknown'); const timestamp = new Date( Number(i['timestamp']) || 0, ).toLocaleString(); return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`; }) .join('\n'); return { name: this.name, data: `Available Checkpoints:\n${formatted}`, }; } catch (_error) { return { name: this.name, data: 'An unexpected error occurred while listing checkpoints.', }; } } } ================================================ FILE: packages/cli/src/acp/commands/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; export interface CommandContext { agentContext: AgentLoopContext; settings: LoadedSettings; git?: GitService; sendMessage: (text: string) => Promise; } export interface CommandArgument { readonly name: string; readonly description: string; readonly isRequired?: boolean; } export interface Command { readonly name: string; readonly aliases?: string[]; readonly description: string; readonly arguments?: CommandArgument[]; readonly subCommands?: Command[]; readonly requiresWorkspace?: boolean; execute( context: CommandContext, args: string[], ): Promise; } export interface CommandExecutionResponse { readonly name: string; readonly data: unknown; } ================================================ FILE: packages/cli/src/acp/fileSystemService.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { AcpFileSystemService } from './fileSystemService.js'; import type { AgentSideConnection } from '@agentclientprotocol/sdk'; import type { FileSystemService } from '@google/gemini-cli-core'; describe('AcpFileSystemService', () => { let mockConnection: Mocked; let mockFallback: Mocked; let service: AcpFileSystemService; beforeEach(() => { mockConnection = { requestPermission: vi.fn(), sessionUpdate: vi.fn(), writeTextFile: vi.fn(), readTextFile: vi.fn(), } as unknown as Mocked; mockFallback = { readTextFile: vi.fn(), writeTextFile: vi.fn(), }; }); describe('readTextFile', () => { it.each([ { capability: true, desc: 'connection if capability exists', setup: () => { mockConnection.readTextFile.mockResolvedValue({ content: 'content' }); }, verify: () => { expect(mockConnection.readTextFile).toHaveBeenCalledWith({ path: '/path/to/file', sessionId: 'session-1', }); expect(mockFallback.readTextFile).not.toHaveBeenCalled(); }, }, { capability: false, desc: 'fallback if capability missing', setup: () => { mockFallback.readTextFile.mockResolvedValue('content'); }, verify: () => { expect(mockFallback.readTextFile).toHaveBeenCalledWith( '/path/to/file', ); expect(mockConnection.readTextFile).not.toHaveBeenCalled(); }, }, ])('should use $desc', async ({ capability, setup, verify }) => { service = new AcpFileSystemService( mockConnection, 'session-1', { readTextFile: capability, writeTextFile: true }, mockFallback, ); setup(); const result = await service.readTextFile('/path/to/file'); expect(result).toBe('content'); verify(); }); }); describe('writeTextFile', () => { it.each([ { capability: true, desc: 'connection if capability exists', verify: () => { expect(mockConnection.writeTextFile).toHaveBeenCalledWith({ path: '/path/to/file', content: 'content', sessionId: 'session-1', }); expect(mockFallback.writeTextFile).not.toHaveBeenCalled(); }, }, { capability: false, desc: 'fallback if capability missing', verify: () => { expect(mockFallback.writeTextFile).toHaveBeenCalledWith( '/path/to/file', 'content', ); expect(mockConnection.writeTextFile).not.toHaveBeenCalled(); }, }, ])('should use $desc', async ({ capability, verify }) => { service = new AcpFileSystemService( mockConnection, 'session-1', { writeTextFile: capability, readTextFile: true }, mockFallback, ); await service.writeTextFile('/path/to/file', 'content'); verify(); }); }); }); ================================================ FILE: packages/cli/src/acp/fileSystemService.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { FileSystemService } from '@google/gemini-cli-core'; import type * as acp from '@agentclientprotocol/sdk'; /** * ACP client-based implementation of FileSystemService */ export class AcpFileSystemService implements FileSystemService { constructor( private readonly connection: acp.AgentSideConnection, private readonly sessionId: string, private readonly capabilities: acp.FileSystemCapabilities, private readonly fallback: FileSystemService, ) {} async readTextFile(filePath: string): Promise { if (!this.capabilities.readTextFile) { return this.fallback.readTextFile(filePath); } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.content; } async writeTextFile(filePath: string, content: string): Promise { if (!this.capabilities.writeTextFile) { return this.fallback.writeTextFile(filePath, content); } await this.connection.writeTextFile({ path: filePath, content, sessionId: this.sessionId, }); } } ================================================ FILE: packages/cli/src/commands/extensions/configure.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import { configureCommand } from './configure.js'; import yargs from 'yargs'; import { debugLogger } from '@google/gemini-cli-core'; import { updateSetting, getScopedEnvContents, type ExtensionSetting, } from '../../config/extensions/extensionSettings.js'; import prompts from 'prompts'; import * as fs from 'node:fs'; const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } = vi.hoisted(() => { const extensionManager = { loadExtensionConfig: vi.fn(), getExtensions: vi.fn(), loadExtensions: vi.fn(), getSettings: vi.fn(), }; return { mockExtensionManager: extensionManager, mockGetExtensionManager: vi.fn(), mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }), }; }); vi.mock('../../config/extension-manager.js', () => ({ ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ updateSetting: vi.fn(), promptForSetting: vi.fn(), getScopedEnvContents: vi.fn(), ExtensionSettingScope: { USER: 'user', WORKSPACE: 'workspace', }, })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); vi.mock('./utils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getExtensionManager: mockGetExtensionManager, }; }); vi.mock('prompts'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); import { ExtensionManager } from '../../config/extension-manager.js'; vi.mock('../../config/settings.js', () => ({ loadSettings: mockLoadSettings, })); describe('extensions configure command', () => { let tempWorkspaceDir: string; beforeEach(() => { vi.spyOn(debugLogger, 'log'); vi.spyOn(debugLogger, 'error'); vi.clearAllMocks(); tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace'); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); // Default behaviors mockLoadSettings.mockReturnValue({ merged: {} }); mockGetExtensionManager.mockResolvedValue(mockExtensionManager); (ExtensionManager as unknown as Mock).mockImplementation( () => mockExtensionManager, ); }); afterEach(() => { vi.restoreAllMocks(); }); const runCommand = async (command: string) => { const parser = yargs().command(configureCommand).help(false).version(false); await parser.parse(command); }; const setupExtension = ( name: string, settings: Array> = [], id = 'test-id', path = '/test/path', ) => { const extension = { name, path, id }; mockExtensionManager.getExtensions.mockReturnValue([extension]); mockExtensionManager.loadExtensionConfig.mockResolvedValue({ name, settings, }); return extension; }; describe('Specific setting configuration', () => { it('should configure a specific setting', async () => { setupExtension('test-ext', [ { name: 'Test Setting', envVar: 'TEST_VAR' }, ]); (updateSetting as Mock).mockResolvedValue(undefined); await runCommand('config test-ext TEST_VAR'); expect(updateSetting).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-ext' }), 'test-id', 'TEST_VAR', expect.any(Function), 'user', tempWorkspaceDir, ); }); it('should handle missing extension', async () => { mockExtensionManager.getExtensions.mockReturnValue([]); await runCommand('config missing-ext TEST_VAR'); expect(updateSetting).not.toHaveBeenCalled(); }); it('should reject invalid extension names', async () => { await runCommand('config ../invalid TEST_VAR'); expect(debugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Invalid extension name'), ); await runCommand('config ext/with/slash TEST_VAR'); expect(debugLogger.error).toHaveBeenCalledWith( expect.stringContaining('Invalid extension name'), ); }); }); describe('Extension configuration (all settings)', () => { it('should configure all settings for an extension', async () => { const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; setupExtension('test-ext', settings); (getScopedEnvContents as Mock).mockResolvedValue({}); (updateSetting as Mock).mockResolvedValue(undefined); await runCommand('config test-ext'); expect(debugLogger.log).toHaveBeenCalledWith( 'Configuring settings for "test-ext"...', ); expect(updateSetting).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-ext' }), 'test-id', 'VAR_1', expect.any(Function), 'user', tempWorkspaceDir, ); }); it('should verify overwrite if setting is already set', async () => { const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; setupExtension('test-ext', settings); (getScopedEnvContents as Mock).mockImplementation( async (_config, _id, scope) => { if (scope === 'user') return { VAR_1: 'existing' }; return {}; }, ); (prompts as unknown as Mock).mockResolvedValue({ confirm: true }); (updateSetting as Mock).mockResolvedValue(undefined); await runCommand('config test-ext'); expect(prompts).toHaveBeenCalledWith( expect.objectContaining({ type: 'confirm', message: expect.stringContaining('is already set. Overwrite?'), }), ); expect(updateSetting).toHaveBeenCalled(); }); it('should note if setting is configured in workspace', async () => { const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; setupExtension('test-ext', settings); (getScopedEnvContents as Mock).mockImplementation( async (_config, _id, scope) => { if (scope === 'workspace') return { VAR_1: 'workspace_value' }; return {}; }, ); (updateSetting as Mock).mockResolvedValue(undefined); await runCommand('config test-ext'); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('is already configured in the workspace scope'), ); }); it('should skip update if user denies overwrite', async () => { const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; setupExtension('test-ext', settings); (getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' }); (prompts as unknown as Mock).mockResolvedValue({ confirm: false }); await runCommand('config test-ext'); expect(prompts).toHaveBeenCalled(); expect(updateSetting).not.toHaveBeenCalled(); }); }); describe('Configure all extensions', () => { it('should configure settings for all installed extensions', async () => { const ext1 = { name: 'ext1', path: '/p1', id: 'id1', settings: [{ envVar: 'V1' }], }; const ext2 = { name: 'ext2', path: '/p2', id: 'id2', settings: [{ envVar: 'V2' }], }; mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]); mockExtensionManager.loadExtensionConfig.mockImplementation( async (path) => { if (path === '/p1') return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] }; if (path === '/p2') return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] }; return null; }, ); (getScopedEnvContents as Mock).mockResolvedValue({}); (updateSetting as Mock).mockResolvedValue(undefined); await runCommand('config'); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('Configuring settings for "ext1"'), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('Configuring settings for "ext2"'), ); expect(updateSetting).toHaveBeenCalledTimes(2); }); it('should log if no extensions installed', async () => { mockExtensionManager.getExtensions.mockReturnValue([]); await runCommand('config'); expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.'); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/configure.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { configureAllExtensions, configureExtension, configureSpecificSetting, getExtensionManager, } from './utils.js'; import { loadSettings } from '../../config/settings.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; interface ConfigureArgs { name?: string; setting?: string; scope: string; } export const configureCommand: CommandModule = { command: 'config [name] [setting]', describe: 'Configure extension settings.', builder: (yargs) => yargs .positional('name', { describe: 'Name of the extension to configure.', type: 'string', }) .positional('setting', { describe: 'The specific setting to configure (name or env var).', type: 'string', }) .option('scope', { describe: 'The scope to set the setting in.', type: 'string', choices: ['user', 'workspace'], default: 'user', }), handler: async (args) => { const { name, setting, scope } = args; const settings = loadSettings(process.cwd()).merged; if (!(settings.experimental?.extensionConfig ?? true)) { coreEvents.emitFeedback( 'error', 'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.', ); await exitCli(); return; } if (name) { if (name.includes('/') || name.includes('\\') || name.includes('..')) { debugLogger.error( 'Invalid extension name. Names cannot contain path separators or "..".', ); return; } } const extensionManager = await getExtensionManager(); // Case 1: Configure specific setting for an extension if (name && setting) { await configureSpecificSetting( extensionManager, name, setting, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } // Case 2: Configure all settings for an extension else if (name) { await configureExtension( extensionManager, name, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } // Case 3: Configure all extensions else { await configureAllExtensions( extensionManager, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope as ExtensionSettingScope, ); } await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/disable.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { format } from 'node:util'; import { type Argv } from 'yargs'; import { handleDisable, disableCommand } from './disable.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, SettingScope, type LoadedSettings, } from '../../config/settings.js'; import { getErrorMessage } from '@google/gemini-cli-core'; // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); }), error: vi.fn((message, ...args) => { emitConsoleLog('error', format(message, ...args)); }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitConsoleLog, }, debugLogger, getErrorMessage: vi.fn(), }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions disable command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockGetErrorMessage = vi.mocked(getErrorMessage); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); mockExtensionManager.prototype.disableExtension = vi .fn() .mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleDisable', () => { it.each([ { name: 'my-extension', scope: undefined, expectedScope: SettingScope.User, expectedLog: 'Extension "my-extension" successfully disabled for scope "undefined".', }, { name: 'my-extension', scope: 'user', expectedScope: SettingScope.User, expectedLog: 'Extension "my-extension" successfully disabled for scope "user".', }, { name: 'my-extension', scope: 'workspace', expectedScope: SettingScope.Workspace, expectedLog: 'Extension "my-extension" successfully disabled for scope "workspace".', }, ])( 'should disable an extension in the $expectedScope scope when scope is $scope', async ({ name, scope, expectedScope, expectedLog }) => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleDisable({ name, scope }); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect( mockExtensionManager.prototype.loadExtensions, ).toHaveBeenCalled(); expect( mockExtensionManager.prototype.disableExtension, ).toHaveBeenCalledWith(name, expectedScope); expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); mockCwd.mockRestore(); }, ); it('should log an error message and exit with code 1 when extension disabling fails', async () => { const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('Disable failed'); ( mockExtensionManager.prototype.disableExtension as Mock ).mockRejectedValue(error); mockGetErrorMessage.mockReturnValue('Disable failed message'); await handleDisable({ name: 'my-extension' }); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Disable failed message', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); }); }); describe('disableCommand', () => { const command = disableCommand; it('should have correct command and describe', () => { expect(command.command).toBe('disable [--scope] '); expect(command.describe).toBe('Disables an extension.'); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure positional and option arguments', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); expect(yargsMock.positional).toHaveBeenCalledWith('name', { describe: 'The name of the extension to disable.', type: 'string', }); expect(yargsMock.option).toHaveBeenCalledWith('scope', { describe: 'The scope to disable the extension in.', type: 'string', default: SettingScope.User, }); expect(yargsMock.check).toHaveBeenCalled(); }); it('check function should throw for invalid scope', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; const expectedError = `Invalid scope: invalid. Please use one of ${Object.values( SettingScope, ) .map((s) => s.toLowerCase()) .join(', ')}.`; expect(() => checkCallback({ scope: 'invalid' })).toThrow( expectedError, ); }); it.each(['user', 'workspace', 'USER', 'WorkSpace'])( 'check function should return true for valid scope "%s"', (scope) => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(checkCallback({ scope })).toBe(true); }, ); }); it('handler should trigger extension disabling', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { name: string; scope: string; [key: string]: unknown; } const argv: TestArgv = { name: 'test-ext', scope: 'workspace', _: [], $0: '', }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); expect( mockExtensionManager.prototype.disableExtension, ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "test-ext" successfully disabled for scope "workspace".', ); mockCwd.mockRestore(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/disable.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface DisableArgs { name: string; scope?: string; } export async function handleDisable(args: DisableArgs) { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); try { if (args.scope?.toLowerCase() === 'workspace') { await extensionManager.disableExtension( args.name, SettingScope.Workspace, ); } else { await extensionManager.disableExtension(args.name, SettingScope.User); } debugLogger.log( `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, ); } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } export const disableCommand: CommandModule = { command: 'disable [--scope] ', describe: 'Disables an extension.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the extension to disable.', type: 'string', }) .option('scope', { describe: 'The scope to disable the extension in.', type: 'string', default: SettingScope.User, }) .check((argv) => { if ( argv.scope && !Object.values(SettingScope) .map((s) => s.toLowerCase()) .includes(argv.scope.toLowerCase()) ) { throw new Error( `Invalid scope: ${argv.scope}. Please use one of ${Object.values( SettingScope, ) .map((s) => s.toLowerCase()) .join(', ')}.`, ); } return true; }), handler: async (argv) => { await handleDisable({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/enable.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { format } from 'node:util'; import { type Argv } from 'yargs'; import { handleEnable, enableCommand } from './enable.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, SettingScope, type LoadedSettings, } from '../../config/settings.js'; import { FatalConfigError } from '@google/gemini-cli-core'; // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); }), error: vi.fn((message, ...args) => { emitConsoleLog('error', format(message, ...args)); }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitConsoleLog, }, debugLogger, getErrorMessage: vi.fn((error: { message: string }) => error.message), FatalConfigError: class extends Error { constructor(message: string) { super(message); this.name = 'FatalConfigError'; } }, }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js'); vi.mock('../../config/extensions/extensionSettings.js'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); const mockEnablementInstance = vi.hoisted(() => ({ getDisplayState: vi.fn(), enable: vi.fn(), clearSessionDisable: vi.fn(), autoEnableServers: vi.fn(), })); vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({ McpServerEnablementManager: { getInstance: () => mockEnablementInstance, }, })); describe('extensions enable command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); mockExtensionManager.prototype.enableExtension = vi.fn(); mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); mockEnablementInstance.getDisplayState.mockReset(); mockEnablementInstance.enable.mockReset(); mockEnablementInstance.clearSessionDisable.mockReset(); mockEnablementInstance.autoEnableServers.mockReset(); mockEnablementInstance.autoEnableServers.mockResolvedValue([]); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleEnable', () => { it.each([ { name: 'my-extension', scope: undefined, expectedScope: SettingScope.User, expectedLog: 'Extension "my-extension" successfully enabled in all scopes.', }, { name: 'my-extension', scope: 'workspace', expectedScope: SettingScope.Workspace, expectedLog: 'Extension "my-extension" successfully enabled for scope "workspace".', }, ])( 'should enable an extension in the $expectedScope scope when scope is $scope', async ({ name, scope, expectedScope, expectedLog }) => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleEnable({ name, scope }); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect( mockExtensionManager.prototype.loadExtensions, ).toHaveBeenCalled(); expect( mockExtensionManager.prototype.enableExtension, ).toHaveBeenCalledWith(name, expectedScope); expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); mockCwd.mockRestore(); }, ); it('should throw FatalConfigError when extension enabling fails', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const error = new Error('Enable failed'); ( mockExtensionManager.prototype.enableExtension as Mock ).mockImplementation(() => { throw error; }); const promise = handleEnable({ name: 'my-extension' }); await expect(promise).rejects.toThrow(FatalConfigError); await expect(promise).rejects.toThrow('Enable failed'); mockCwd.mockRestore(); }); it('should auto-enable disabled MCP servers for the extension', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockEnablementInstance.autoEnableServers.mockResolvedValue([ 'test-server', ]); mockExtensionManager.prototype.getExtensions = vi .fn() .mockReturnValue([ { name: 'my-extension', mcpServers: { 'test-server': {} } }, ]); await handleEnable({ name: 'my-extension' }); expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ 'test-server', ]); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining("MCP server 'test-server' was disabled"), ); mockCwd.mockRestore(); }); it('should not log when MCP servers are already enabled', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockEnablementInstance.autoEnableServers.mockResolvedValue([]); mockExtensionManager.prototype.getExtensions = vi .fn() .mockReturnValue([ { name: 'my-extension', mcpServers: { 'test-server': {} } }, ]); await handleEnable({ name: 'my-extension' }); expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ 'test-server', ]); expect(emitConsoleLog).not.toHaveBeenCalledWith( 'log', expect.stringContaining("MCP server 'test-server' was disabled"), ); mockCwd.mockRestore(); }); }); describe('enableCommand', () => { const command = enableCommand; it('should have correct command and describe', () => { expect(command.command).toBe('enable [--scope] '); expect(command.describe).toBe('Enables an extension.'); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure positional and option arguments', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); expect(yargsMock.positional).toHaveBeenCalledWith('name', { describe: 'The name of the extension to enable.', type: 'string', }); expect(yargsMock.option).toHaveBeenCalledWith('scope', { describe: 'The scope to enable the extension in. If not set, will be enabled in all scopes.', type: 'string', }); expect(yargsMock.check).toHaveBeenCalled(); }); it('check function should throw for invalid scope', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; const expectedError = `Invalid scope: invalid. Please use one of ${Object.values( SettingScope, ) .map((s) => s.toLowerCase()) .join(', ')}.`; expect(() => checkCallback({ scope: 'invalid' })).toThrow( expectedError, ); }); }); it('handler should call handleEnable', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { name: string; scope: string; [key: string]: unknown; } const argv: TestArgv = { name: 'test-ext', scope: 'workspace', _: [], $0: '', }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); expect( mockExtensionManager.prototype.enableExtension, ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace); mockCwd.mockRestore(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/enable.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { debugLogger, FatalConfigError, getErrorMessage, } from '@google/gemini-cli-core'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; interface EnableArgs { name: string; scope?: string; } export async function handleEnable(args: EnableArgs) { const workingDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir: workingDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workingDir).merged, }); await extensionManager.loadExtensions(); try { if (args.scope?.toLowerCase() === 'workspace') { await extensionManager.enableExtension(args.name, SettingScope.Workspace); } else { await extensionManager.enableExtension(args.name, SettingScope.User); } // Auto-enable any disabled MCP servers for this extension const extension = extensionManager .getExtensions() .find((e) => e.name === args.name); if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); for (const serverName of enabledServers) { debugLogger.log( `MCP server '${serverName}' was disabled - now enabled.`, ); } // Note: No restartServer() - CLI exits immediately, servers load on next session } if (args.scope) { debugLogger.log( `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, ); } else { debugLogger.log( `Extension "${args.name}" successfully enabled in all scopes.`, ); } } catch (error) { throw new FatalConfigError(getErrorMessage(error)); } } export const enableCommand: CommandModule = { command: 'enable [--scope] ', describe: 'Enables an extension.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the extension to enable.', type: 'string', }) .option('scope', { describe: 'The scope to enable the extension in. If not set, will be enabled in all scopes.', type: 'string', }) .check((argv) => { if ( argv.scope && !Object.values(SettingScope) .map((s) => s.toLowerCase()) .includes(argv.scope.toLowerCase()) ) { throw new Error( `Invalid scope: ${argv.scope}. Please use one of ${Object.values( SettingScope, ) .map((s) => s.toLowerCase()) .join(', ')}.`, ); } return true; }), handler: async (argv) => { await handleEnable({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/examples/custom-commands/.gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-error.log yarn-debug.log # Build output dist/ # OS metadata .DS_Store Thumbs.db # TypeScript *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDEs .vscode/ .idea/ ================================================ FILE: packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml ================================================ prompt = """ Please summarize the findings for the pattern `{{args}}`. Search Results: !{grep -r {{args}} .} """ ================================================ FILE: packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json ================================================ { "name": "custom-commands", "version": "1.0.0" } ================================================ FILE: packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-error.log yarn-debug.log # Build output dist/ # OS metadata .DS_Store Thumbs.db # TypeScript *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDEs .vscode/ .idea/ ================================================ FILE: packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json ================================================ { "name": "excludeTools", "version": "1.0.0", "excludeTools": ["run_shell_command(rm -rf)"] } ================================================ FILE: packages/cli/src/commands/extensions/examples/hooks/.gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-error.log yarn-debug.log # Build output dist/ # OS metadata .DS_Store Thumbs.db # TypeScript *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDEs .vscode/ .idea/ ================================================ FILE: packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json ================================================ { "name": "hooks-example", "version": "1.0.0" } ================================================ FILE: packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json ================================================ { "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "node ${extensionPath}/scripts/on-start.js" } ] } ] } } ================================================ FILE: packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ console.log( 'Session Started! This is running from a script in the hooks-example extension.', ); ================================================ FILE: packages/cli/src/commands/extensions/examples/mcp-server/.gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-error.log yarn-debug.log # Build output dist/ # OS metadata .DS_Store Thumbs.db # TypeScript *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDEs .vscode/ .idea/ ================================================ FILE: packages/cli/src/commands/extensions/examples/mcp-server/README.md ================================================ # MCP Server Example This is a basic example of an MCP (Model Context Protocol) server used as a Gemini CLI extension. It demonstrates how to expose tools and prompts to the Gemini CLI. ## Description The contents of this directory are a valid MCP server implementation using the `@modelcontextprotocol/sdk`. It exposes: - A tool `fetch_posts` that mock-fetches posts. - A prompt `poem-writer`. ## Structure - `example.js`: The main server entry point. - `gemini-extension.json`: The configuration file that tells Gemini CLI how to use this extension. - `package.json`: Helper for dependencies. ## How to Use 1. Navigate to this directory: ```bash cd packages/cli/src/commands/extensions/examples/mcp-server ``` 2. Install dependencies: ```bash npm install ``` This example is typically used by `gemini extensions new`. ================================================ FILE: packages/cli/src/commands/extensions/examples/mcp-server/example.js ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; const server = new McpServer({ name: 'prompt-server', version: '1.0.0', }); server.registerTool( 'fetch_posts', { description: 'Fetches a list of posts from a public API.', inputSchema: z.object({}).shape, }, async () => { const apiResponse = await fetch( 'https://jsonplaceholder.typicode.com/posts', ); const posts = await apiResponse.json(); const response = { posts: posts.slice(0, 5) }; return { content: [ { type: 'text', text: JSON.stringify(response), }, ], }; }, ); server.registerPrompt( 'poem-writer', { title: 'Poem Writer', description: 'Write a nice haiku', argsSchema: { title: z.string(), mood: z.string().optional() }, }, ({ title, mood }) => ({ messages: [ { role: 'user', content: { type: 'text', text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `, }, }, ], }), ); const transport = new StdioServerTransport(); await server.connect(transport); ================================================ FILE: packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json ================================================ { "name": "mcp-server-example", "version": "1.0.0", "mcpServers": { "nodeServer": { "command": "node", "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } } ================================================ FILE: packages/cli/src/commands/extensions/examples/mcp-server/package.json ================================================ { "name": "mcp-server-example", "version": "1.0.0", "description": "Example MCP Server for Gemini CLI Extension", "type": "module", "main": "example.js", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", "zod": "^3.22.4" } } ================================================ FILE: packages/cli/src/commands/extensions/examples/policies/README.md ================================================ # Policy engine example extension This extension demonstrates how to contribute security rules and safety checkers to the Gemini CLI Policy Engine. ## Description The extension uses a `policies/` directory containing `.toml` files to define: - A rule that requires user confirmation for `rm -rf` commands. - A rule that denies searching for sensitive files (like `.env`) using `grep`. - A safety checker that validates file paths for all write operations. ## Structure - `gemini-extension.json`: The manifest file. - `policies/`: Contains the `.toml` policy files. ## How to use 1. Link this extension to your local Gemini CLI installation: ```bash gemini extensions link packages/cli/src/commands/extensions/examples/policies ``` 2. Restart your Gemini CLI session. 3. **Observe the policies:** - Try asking the model to delete a directory: The policy engine will prompt you for confirmation due to the `rm -rf` rule. - Try asking the model to search for secrets: The `grep` rule will deny the request and display the custom deny message. - Any file write operation will now be processed through the `allowed-path` safety checker. ## Security note For security, Gemini CLI ignores any `allow` decisions or `yolo` mode configurations contributed by extensions. This ensures that extensions can strengthen security but cannot bypass user confirmation. ================================================ FILE: packages/cli/src/commands/extensions/examples/policies/gemini-extension.json ================================================ { "name": "policy-example", "version": "1.0.0", "description": "An example extension demonstrating Policy Engine support." } ================================================ FILE: packages/cli/src/commands/extensions/examples/policies/policies/policies.toml ================================================ # Example Policy Rules for Gemini CLI Extension # # Extensions run in Tier 2 (Extension Tier). # Security Note: 'allow' decisions and 'yolo' mode configurations are ignored. # Rule: Always ask the user before running a specific dangerous shell command. [[rule]] toolName = "run_shell_command" commandPrefix = "rm -rf" decision = "ask_user" priority = 100 # Rule: Deny access to sensitive files using the grep tool. [[rule]] toolName = "grep_search" argsPattern = "(\.env|id_rsa|passwd)" decision = "deny" priority = 200 deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension." # Safety Checker: Apply path validation to all write operations. [[safety_checker]] toolName = ["write_file", "replace"] priority = 300 [safety_checker.checker] type = "in-process" name = "allowed-path" required_context = ["environment"] ================================================ FILE: packages/cli/src/commands/extensions/examples/skills/.gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-error.log yarn-debug.log # Build output dist/ # OS metadata .DS_Store Thumbs.db # TypeScript *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDEs .vscode/ .idea/ ================================================ FILE: packages/cli/src/commands/extensions/examples/skills/gemini-extension.json ================================================ { "name": "skills-example", "version": "1.0.0" } ================================================ FILE: packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md ================================================ --- name: greeter description: A friendly greeter skill --- You are a friendly greeter. When the user says "hello" or asks for a greeting, you should reply with: "Greetings from the skills-example extension! 👋" ================================================ FILE: packages/cli/src/commands/extensions/examples/themes-example/README.md ================================================ # Themes Example This is an example of a Gemini CLI extension that adds a custom theme. ## How to use 1. Link this extension: ```bash gemini extensions link packages/cli/src/commands/extensions/examples/themes-example ``` 2. Set the theme in your settings file (`~/.gemini/settings.json`): ```json { "ui": { "theme": "shades-of-green (themes-example)" } } ``` Alternatively, you can set it through the UI by running `gemini` and then typing `/theme` and pressing Enter. 3. **Observe the Changes:** After setting the theme, you should see the changes reflected in the Gemini CLI's UI. The background will be a dark green, the primary text a lighter green, and various other UI elements will display different shades of green, as defined in this extension's `gemini-extension.json` file. ================================================ FILE: packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json ================================================ { "name": "themes-example", "version": "1.0.0", "themes": [ { "name": "shades-of-green", "type": "custom", "background": { "primary": "#1a362a" }, "text": { "primary": "#a6e3a1", "secondary": "#6e8e7a", "link": "#89e689" }, "status": { "success": "#76c076", "warning": "#d9e689", "error": "#b34e4e" }, "border": { "default": "#4a6c5a" }, "ui": { "comment": "#6e8e7a" } } ] } ================================================ FILE: packages/cli/src/commands/extensions/install.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance, type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; import { ExtensionManager, type inferInstallMetadata, } from '../../config/extension-manager.js'; import type { promptForConsentNonInteractive, requestConsentNonInteractive, } from '../../config/extensions/consent.js'; import type { isWorkspaceTrusted, loadTrustedFolders, } from '../../config/trustedFolders.js'; import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; const mockInstallOrUpdateExtension: Mock< typeof ExtensionManager.prototype.installOrUpdateExtension > = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive: Mock< typeof requestConsentNonInteractive > = vi.hoisted(() => vi.fn()); const mockPromptForConsentNonInteractive: Mock< typeof promptForConsentNonInteractive > = vi.hoisted(() => vi.fn()); const mockStat: Mock = vi.hoisted(() => vi.fn()); const mockInferInstallMetadata: Mock = vi.hoisted( () => vi.fn(), ); const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => vi.fn(), ); const mockLoadTrustedFolders: Mock = vi.hoisted(() => vi.fn(), ); const mockDiscover: Mock = vi.hoisted(() => vi.fn()); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, promptForConsentNonInteractive: mockPromptForConsentNonInteractive, INSTALL_WARNING_MESSAGE: 'warning', })); vi.mock('../../config/trustedFolders.js', () => ({ isWorkspaceTrusted: mockIsWorkspaceTrusted, loadTrustedFolders: mockLoadTrustedFolders, TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER', }, })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, FolderTrustDiscoveryService: { discover: mockDiscover, }, }; }); vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), inferInstallMetadata: mockInferInstallMetadata, })); vi.mock('../../utils/errors.js', () => ({ getErrorMessage: vi.fn((error: Error) => error.message), })); vi.mock('node:fs/promises', () => ({ stat: mockStat, default: { stat: mockStat, }, })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]).command(installCommand).fail(false); expect(() => validationParser.parse('install')).toThrow( 'Not enough non-option arguments: got 0, need at least 1', ); }); }); describe('handleInstall', () => { let debugLogSpy: MockInstance; let debugErrorSpy: MockInstance; let processSpy: MockInstance; beforeEach(() => { debugLogSpy = vi.spyOn(core.debugLogger, 'log'); debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( [], ); vi.spyOn( ExtensionManager.prototype, 'installOrUpdateExtension', ).mockImplementation(mockInstallOrUpdateExtension); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ commands: [], mcps: [], hooks: [], skills: [], agents: [], settings: [], securityWarnings: [], discoveryErrors: [], }); mockInferInstallMetadata.mockImplementation(async (source, args) => { if ( source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@') || source.startsWith('sso://') ) { return { source, type: 'git', ref: args?.ref, autoUpdate: args?.autoUpdate, allowPreRelease: args?.allowPreRelease, }; } return { source, type: 'local' }; }); }); afterEach(() => { mockInstallOrUpdateExtension.mockClear(); mockRequestConsentNonInteractive.mockClear(); mockStat.mockClear(); mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); vi.restoreAllMocks(); }); function createMockExtension( overrides: Partial = {}, ): core.GeminiCLIExtension { return { name: 'mock-extension', version: '1.0.0', isActive: true, path: '/mock/path', contextFiles: [], id: 'mock-id', ...overrides, }; } it('should install an extension from a http source', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'http-extension', }), ); await handleInstall({ source: 'http://google.com', }); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "http-extension" installed successfully and enabled.', ); }); it('should install an extension from a https source', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'https-extension', }), ); await handleInstall({ source: 'https://google.com', }); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "https-extension" installed successfully and enabled.', ); }); it('should install an extension from a git source', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'git-extension', }), ); await handleInstall({ source: 'git@some-url', }); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "git-extension" installed successfully and enabled.', ); }); it('throws an error from an unknown source', async () => { mockInferInstallMetadata.mockRejectedValue( new Error('Install source not found.'), ); await handleInstall({ source: 'test://google.com', }); expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.'); expect(processSpy).toHaveBeenCalledWith(1); }); it('should install an extension from a sso source', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'sso-extension', }), ); await handleInstall({ source: 'sso://google.com', }); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "sso-extension" installed successfully and enabled.', ); }); it('should install an extension from a local path', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'local-extension', }), ); mockStat.mockResolvedValue({} as Stats); await handleInstall({ source: path.join('/', 'some', 'path'), }); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "local-extension" installed successfully and enabled.', ); }); it('should throw an error if install extension fails', async () => { mockInstallOrUpdateExtension.mockRejectedValue( new Error('Install extension failed'), ); await handleInstall({ source: 'git@some-url' }); expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed'); expect(processSpy).toHaveBeenCalledWith(1); }); it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'local-extension', }), ); mockStat.mockResolvedValue({} as Stats); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); await handleInstall({ source: path.join('/', 'some', 'path'), }); expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled(); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "local-extension" installed successfully and enabled.', ); }); it('should prompt and proceed if user accepts trust', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'local-extension', }), ); mockStat.mockResolvedValue({} as Stats); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: undefined, source: undefined, }); mockPromptForConsentNonInteractive.mockResolvedValue(true); const mockSetValue = vi.fn(); mockLoadTrustedFolders.mockReturnValue({ setValue: mockSetValue, user: { path: '', config: {} }, errors: [], rules: [], isPathTrusted: vi.fn(), }); await handleInstall({ source: path.join('/', 'untrusted', 'path'), }); expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); expect(mockPromptForConsentNonInteractive).toHaveBeenCalled(); expect(mockSetValue).toHaveBeenCalledWith( expect.stringContaining(path.join('untrusted', 'path')), 'TRUST_FOLDER', ); expect(debugLogSpy).toHaveBeenCalledWith( 'Extension "local-extension" installed successfully and enabled.', ); }); it('should prompt and abort if user denies trust', async () => { mockStat.mockResolvedValue({} as Stats); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: undefined, source: undefined, }); mockPromptForConsentNonInteractive.mockResolvedValue(false); await handleInstall({ source: path.join('/', 'evil', 'path'), }); expect(mockIsWorkspaceTrusted).toHaveBeenCalled(); expect(mockPromptForConsentNonInteractive).toHaveBeenCalled(); expect(debugErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Installation aborted: Folder'), ); expect(processSpy).toHaveBeenCalledWith(1); }); it('should include discovery results in trust prompt', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ name: 'local-extension', }), ); mockStat.mockResolvedValue({} as Stats); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: undefined, source: undefined, }); mockDiscover.mockResolvedValue({ commands: ['custom-cmd'], mcps: [], hooks: [], skills: ['cool-skill'], agents: ['cool-agent'], settings: [], securityWarnings: ['Security risk!'], discoveryErrors: ['Read error'], }); mockPromptForConsentNonInteractive.mockResolvedValue(true); mockLoadTrustedFolders.mockReturnValue({ setValue: vi.fn(), user: { path: '', config: {} }, errors: [], rules: [], isPathTrusted: vi.fn(), }); await handleInstall({ source: '/untrusted/path', }); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('This folder contains:'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('custom-cmd'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('cool-skill'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('cool-agent'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Security Warnings:'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Security risk!'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Discovery Errors:'), false, ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Read error'), false, ); }); }); // Implementation completed. ================================================ FILE: packages/cli/src/commands/extensions/install.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import * as path from 'node:path'; import chalk from 'chalk'; import { debugLogger, FolderTrustDiscoveryService, getRealPath, getErrorMessage, } from '@google/gemini-cli-core'; import { INSTALL_WARNING_MESSAGE, promptForConsentNonInteractive, requestConsentNonInteractive, } from '../../config/extensions/consent.js'; import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { isWorkspaceTrusted, loadTrustedFolders, TrustLevel, } from '../../config/trustedFolders.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface InstallArgs { source: string; ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; } export async function handleInstall(args: InstallArgs) { try { const { source } = args; const installMetadata = await inferInstallMetadata(source, { ref: args.ref, autoUpdate: args.autoUpdate, allowPreRelease: args.allowPreRelease, }); const workspaceDir = process.cwd(); const settings = loadSettings(workspaceDir).merged; if (installMetadata.type === 'local' || installMetadata.type === 'link') { const absolutePath = path.resolve(source); const realPath = getRealPath(absolutePath); installMetadata.source = absolutePath; const trustResult = isWorkspaceTrusted(settings, absolutePath); if (trustResult.isTrusted !== true) { const discoveryResults = await FolderTrustDiscoveryService.discover(realPath); const hasDiscovery = discoveryResults.commands.length > 0 || discoveryResults.mcps.length > 0 || discoveryResults.hooks.length > 0 || discoveryResults.skills.length > 0 || discoveryResults.settings.length > 0; const promptLines = [ '', chalk.bold('Do you trust the files in this folder?'), '', `The extension source at "${absolutePath}" is not trusted.`, '', 'Trusting a folder allows Gemini CLI to load its local configurations,', 'including custom commands, hooks, MCP servers, agent skills, and', 'settings. These configurations could execute code on your behalf or', 'change the behavior of the CLI.', '', ]; if (discoveryResults.discoveryErrors.length > 0) { promptLines.push(chalk.red('❌ Discovery Errors:')); for (const error of discoveryResults.discoveryErrors) { promptLines.push(chalk.red(` • ${error}`)); } promptLines.push(''); } if (discoveryResults.securityWarnings.length > 0) { promptLines.push(chalk.yellow('⚠️ Security Warnings:')); for (const warning of discoveryResults.securityWarnings) { promptLines.push(chalk.yellow(` • ${warning}`)); } promptLines.push(''); } if (hasDiscovery) { promptLines.push(chalk.bold('This folder contains:')); const groups = [ { label: 'Commands', items: discoveryResults.commands ?? [] }, { label: 'MCP Servers', items: discoveryResults.mcps ?? [] }, { label: 'Hooks', items: discoveryResults.hooks ?? [] }, { label: 'Skills', items: discoveryResults.skills ?? [] }, { label: 'Agents', items: discoveryResults.agents ?? [] }, { label: 'Setting overrides', items: discoveryResults.settings ?? [], }, ].filter((g) => g.items.length > 0); for (const group of groups) { promptLines.push( ` • ${chalk.bold(group.label)} (${group.items.length}):`, ); for (const item of group.items) { promptLines.push(` - ${item}`); } } promptLines.push(''); } promptLines.push( chalk.yellow( 'Do you want to trust this folder and continue with the installation? [y/N]: ', ), ); const confirmed = await promptForConsentNonInteractive( promptLines.join('\n'), false, ); if (confirmed) { const trustedFolders = loadTrustedFolders(); await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); } else { throw new Error( `Installation aborted: Folder "${absolutePath}" is not trusted.`, ); } } } const requestConsent = args.consent ? () => Promise.resolve(true) : requestConsentNonInteractive; if (args.consent) { debugLogger.log('You have consented to the following:'); debugLogger.log(INSTALL_WARNING_MESSAGE); } const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, requestSetting: promptForSetting, settings, }); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log( `Extension "${extension.name}" installed successfully and enabled.`, ); } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } export const installCommand: CommandModule = { command: 'install [--auto-update] [--pre-release]', describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs .positional('source', { describe: 'The github URL or local path of the extension to install.', type: 'string', demandOption: true, }) .option('ref', { describe: 'The git ref to install from.', type: 'string', }) .option('auto-update', { describe: 'Enable auto-update for this extension.', type: 'boolean', }) .option('pre-release', { describe: 'Enable pre-release versions for this extension.', type: 'boolean', }) .option('consent', { describe: 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', type: 'boolean', default: false, }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); } return true; }), handler: async (argv) => { await handleInstall({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion source: argv['source'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ref: argv['ref'] as string | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion autoUpdate: argv['auto-update'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/link.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; import { type Argv } from 'yargs'; import { handleLink, linkCommand } from './link.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const { mockCoreDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); const actual = await importOriginal(); const mocked = mockCoreDebugLogger(actual, { stripAnsi: true }); return { ...mocked, getErrorMessage: vi.fn() }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions link command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockGetErrorMessage = vi.mocked(getErrorMessage); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); mockExtensionManager.prototype.installOrUpdateExtension = vi .fn() .mockResolvedValue({ name: 'my-linked-extension' }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleLink', () => { it('should link an extension from a local path', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleLink({ path: '/local/path/to/extension' }); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); expect( mockExtensionManager.prototype.installOrUpdateExtension, ).toHaveBeenCalledWith({ source: '/local/path/to/extension', type: 'link', }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "my-linked-extension" linked successfully and enabled.', ); mockCwd.mockRestore(); }); it('should log an error message and exit with code 1 when linking fails', async () => { const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('Link failed'); ( mockExtensionManager.prototype.installOrUpdateExtension as Mock ).mockRejectedValue(error); mockGetErrorMessage.mockReturnValue('Link failed message'); await handleLink({ path: '/local/path/to/extension' }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'error', 'Link failed message', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); }); }); describe('linkCommand', () => { const command = linkCommand; it('should have correct command and describe', () => { expect(command.command).toBe('link '); expect(command.describe).toBe( 'Links an extension from a local path. Updates made to the local path will always be reflected.', ); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure positional argument', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); expect(yargsMock.positional).toHaveBeenCalledWith('path', { describe: 'The name of the extension to link.', type: 'string', }); expect(yargsMock.option).toHaveBeenCalledWith('consent', { describe: 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', type: 'boolean', default: false, }); expect(yargsMock.check).toHaveBeenCalled(); }); }); it('handler should call handleLink', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { path: string; [key: string]: unknown; } const argv: TestArgv = { path: '/local/path/to/extension', _: [], $0: '', }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); expect( mockExtensionManager.prototype.installOrUpdateExtension, ).toHaveBeenCalledWith({ source: '/local/path/to/extension', type: 'link', }); mockCwd.mockRestore(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/link.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import chalk from 'chalk'; import { debugLogger, getErrorMessage, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import { INSTALL_WARNING_MESSAGE, requestConsentNonInteractive, } from '../../config/extensions/consent.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface InstallArgs { path: string; consent?: boolean; } export async function handleLink(args: InstallArgs) { try { const installMetadata: ExtensionInstallMetadata = { source: args.path, type: 'link', }; const requestConsent = args.consent ? () => Promise.resolve(true) : requestConsentNonInteractive; if (args.consent) { debugLogger.log('You have consented to the following:'); debugLogger.log(INSTALL_WARNING_MESSAGE); } const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log( chalk.green( `Extension "${extension.name}" linked successfully and enabled.`, ), ); } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } export const linkCommand: CommandModule = { command: 'link ', describe: 'Links an extension from a local path. Updates made to the local path will always be reflected.', builder: (yargs) => yargs .positional('path', { describe: 'The name of the extension to link.', type: 'string', }) .option('consent', { describe: 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', type: 'boolean', default: false, }) .check((_) => true), handler: async (argv) => { await handleLink({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/list.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const { mockCoreDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); const actual = await importOriginal(); const mocked = mockCoreDebugLogger(actual, { stripAnsi: false }); return { ...mocked, getErrorMessage: vi.fn() }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions list command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockGetErrorMessage = vi.mocked(getErrorMessage); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleList', () => { it('should log a message if no extensions are installed', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); await handleList(); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'No extensions installed.', ); mockCwd.mockRestore(); }); it('should output empty JSON array if no extensions are installed and output-format is json', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); await handleList({ outputFormat: 'json' }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]'); mockCwd.mockRestore(); }); it('should list all installed extensions', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const extensions = [ { name: 'ext1', version: '1.0.0' }, { name: 'ext2', version: '2.0.0' }, ]; mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(extensions); mockExtensionManager.prototype.toOutputString = vi.fn( (ext) => `${ext.name}@${ext.version}`, ); await handleList(); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'ext1@1.0.0\n\next2@2.0.0', ); mockCwd.mockRestore(); }); it('should list all installed extensions in JSON format', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const extensions = [ { name: 'ext1', version: '1.0.0' }, { name: 'ext2', version: '2.0.0' }, ]; mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(extensions); await handleList({ outputFormat: 'json' }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', JSON.stringify(extensions, null, 2), ); mockCwd.mockRestore(); }); it('should log an error message and exit with code 1 when listing fails', async () => { const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('List failed'); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockRejectedValue(error); mockGetErrorMessage.mockReturnValue('List failed message'); await handleList(); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'error', 'List failed message', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); }); }); describe('listCommand', () => { const command = listCommand; it('should have correct command and describe', () => { expect(command.command).toBe('list'); expect(command.describe).toBe('Lists installed extensions.'); }); it('builder should have output-format option', () => { const mockYargs = { option: vi.fn().mockReturnThis(), }; ( command.builder as unknown as ( yargs: typeof mockYargs, ) => typeof mockYargs )(mockYargs); expect(mockYargs.option).toHaveBeenCalledWith('output-format', { alias: 'o', type: 'string', describe: 'The format of the CLI output.', choices: ['text', 'json'], default: 'text', }); }); it('handler should call handleList with parsed arguments', async () => { mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); await ( command.handler as unknown as (args: { 'output-format': string; }) => Promise )({ 'output-format': 'json', }); expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/list.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; export async function handleList(options?: { outputFormat?: 'text' | 'json' }) { try { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); const extensions = await extensionManager.loadExtensions(); if (extensions.length === 0) { if (options?.outputFormat === 'json') { debugLogger.log('[]'); } else { debugLogger.log('No extensions installed.'); } return; } if (options?.outputFormat === 'json') { debugLogger.log(JSON.stringify(extensions, null, 2)); } else { debugLogger.log( extensions .map((extension, _): string => extensionManager.toOutputString(extension), ) .join('\n\n'), ); } } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } export const listCommand: CommandModule = { command: 'list', describe: 'Lists installed extensions.', builder: (yargs) => yargs.option('output-format', { alias: 'o', type: 'string', describe: 'The format of the CLI output.', choices: ['text', 'json'], default: 'text', }), handler: async (argv) => { await handleList({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion outputFormat: argv['output-format'] as 'text' | 'json', }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/new.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { newCommand } from './new.js'; import yargs from 'yargs'; import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; vi.mock('node:fs/promises'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); const mockedFs = vi.mocked(fsPromises); describe('extensions new command', () => { beforeEach(() => { vi.resetAllMocks(); const fakeFiles = [ { name: 'context', isDirectory: () => true }, { name: 'custom-commands', isDirectory: () => true }, { name: 'mcp-server', isDirectory: () => true }, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any mockedFs.readdir.mockResolvedValue(fakeFiles as any); }); it('should fail if no path is provided', async () => { const parser = yargs([]).command(newCommand).fail(false).locale('en'); await expect(parser.parseAsync('new')).rejects.toThrow( 'Not enough non-option arguments: got 0, need at least 1', ); }); it('should create directory when no template is provided', async () => { mockedFs.access.mockRejectedValue(new Error('ENOENT')); mockedFs.mkdir.mockResolvedValue(undefined); const parser = yargs([]).command(newCommand).fail(false); await parser.parseAsync('new /some/path'); expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', { recursive: true, }); expect(mockedFs.cp).not.toHaveBeenCalled(); }); it('should create directory and copy files when path does not exist', async () => { mockedFs.access.mockRejectedValue(new Error('ENOENT')); mockedFs.mkdir.mockResolvedValue(undefined); mockedFs.cp.mockResolvedValue(undefined); const parser = yargs([]).command(newCommand).fail(false); await parser.parseAsync('new /some/path context'); expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', { recursive: true, }); expect(mockedFs.cp).toHaveBeenCalledWith( expect.stringContaining(path.normalize('context/context')), path.normalize('/some/path/context'), { recursive: true }, ); expect(mockedFs.cp).toHaveBeenCalledWith( expect.stringContaining(path.normalize('context/custom-commands')), path.normalize('/some/path/custom-commands'), { recursive: true }, ); expect(mockedFs.cp).toHaveBeenCalledWith( expect.stringContaining(path.normalize('context/mcp-server')), path.normalize('/some/path/mcp-server'), { recursive: true }, ); }); it('should throw an error if the path already exists', async () => { mockedFs.access.mockResolvedValue(undefined); const parser = yargs([]).command(newCommand).fail(false); await expect(parser.parseAsync('new /some/path context')).rejects.toThrow( 'Path already exists: /some/path', ); expect(mockedFs.mkdir).not.toHaveBeenCalled(); expect(mockedFs.cp).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/commands/extensions/new.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises'; import { join, dirname, basename } from 'node:path'; import type { CommandModule } from 'yargs'; import { fileURLToPath } from 'node:url'; import { debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; interface NewArgs { path: string; template?: string; } const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const EXAMPLES_PATH = join(__dirname, 'examples'); async function pathExists(path: string) { try { await access(path); return true; } catch (_e) { return false; } } async function createDirectory(path: string) { if (await pathExists(path)) { throw new Error(`Path already exists: ${path}`); } await mkdir(path, { recursive: true }); } async function copyDirectory(template: string, path: string) { await createDirectory(path); const examplePath = join(EXAMPLES_PATH, template); const entries = await readdir(examplePath, { withFileTypes: true }); for (const entry of entries) { const srcPath = join(examplePath, entry.name); const destPath = join(path, entry.name); await cp(srcPath, destPath, { recursive: true }); } } async function handleNew(args: NewArgs) { if (args.template) { await copyDirectory(args.template, args.path); debugLogger.log( `Successfully created new extension from template "${args.template}" at ${args.path}.`, ); } else { await createDirectory(args.path); const extensionName = basename(args.path); const manifest = { name: extensionName, version: '1.0.0', }; await writeFile( join(args.path, 'gemini-extension.json'), JSON.stringify(manifest, null, 2), ); debugLogger.log(`Successfully created new extension at ${args.path}.`); } debugLogger.log( `You can install this using "gemini extensions link ${args.path}" to test it out.`, ); } async function getBoilerplateChoices() { const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } export const newCommand: CommandModule = { command: 'new [template]', describe: 'Create a new extension from a boilerplate example.', builder: async (yargs) => { const choices = await getBoilerplateChoices(); return yargs .positional('path', { describe: 'The path to create the extension in.', type: 'string', }) .positional('template', { describe: 'The boilerplate template to use.', type: 'string', choices, }); }, handler: async (args) => { await handleNew({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: args['path'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion template: args['template'] as string | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/uninstall.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { format } from 'node:util'; import { type Argv } from 'yargs'; import { handleUninstall, uninstallCommand } from './uninstall.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { getErrorMessage } from '@google/gemini-cli-core'; // NOTE: This file uses vi.hoisted() mocks to enable testing of sequential // mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining). // The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary // for testing partial failure scenarios in the multiple extension uninstall feature. // Hoisted mocks - these survive vi.clearAllMocks() const mockUninstallExtension = vi.hoisted(() => vi.fn()); const mockLoadExtensions = vi.hoisted(() => vi.fn()); const mockGetExtensions = vi.hoisted(() => vi.fn()); // Mock dependencies with hoisted functions vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ExtensionManager: vi.fn().mockImplementation(() => ({ uninstallExtension: mockUninstallExtension, loadExtensions: mockLoadExtensions, getExtensions: mockGetExtensions, setRequestConsent: vi.fn(), setRequestSetting: vi.fn(), })), }; }); // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); }), error: vi.fn((message, ...args) => { emitConsoleLog('error', format(message, ...args)); }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitConsoleLog, }, debugLogger, getErrorMessage: vi.fn(), }; }); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions uninstall command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockGetErrorMessage = vi.mocked(getErrorMessage); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); }); afterEach(() => { mockLoadExtensions.mockClear(); mockUninstallExtension.mockClear(); mockGetExtensions.mockClear(); vi.clearAllMocks(); }); describe('handleUninstall', () => { it('should uninstall a single extension', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ names: ['my-extension'] }); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect(mockLoadExtensions).toHaveBeenCalled(); expect(mockUninstallExtension).toHaveBeenCalledWith( 'my-extension', false, ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "my-extension" successfully uninstalled.', ); mockCwd.mockRestore(); }); it('should uninstall multiple extensions', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] }); expect(mockUninstallExtension).toHaveBeenCalledTimes(3); expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext2" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext3" successfully uninstalled.', ); mockCwd.mockRestore(); }); it('should uninstall all extensions when --all flag is used', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ all: true }); expect(mockUninstallExtension).toHaveBeenCalledTimes(2); expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext2" successfully uninstalled.', ); mockCwd.mockRestore(); }); it('should log a message if no extensions are installed and --all flag is used', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockGetExtensions.mockReturnValue([]); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ all: true }); expect(mockUninstallExtension).not.toHaveBeenCalled(); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'No extensions currently installed.', ); mockCwd.mockRestore(); }); it('should report errors for failed uninstalls but continue with others', async () => { mockLoadExtensions.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('Extension not found'); // Chain sequential mock behaviors - this works with hoisted mocks mockUninstallExtension .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(error) .mockResolvedValueOnce(undefined); mockGetErrorMessage.mockReturnValue('Extension not found'); await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] }); expect(mockUninstallExtension).toHaveBeenCalledTimes(3); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Failed to uninstall "ext2": Extension not found', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext3" successfully uninstalled.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); mockCwd.mockRestore(); }); it('should exit with error code if all uninstalls fail', async () => { mockLoadExtensions.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('Extension not found'); mockUninstallExtension.mockRejectedValue(error); mockGetErrorMessage.mockReturnValue('Extension not found'); await handleUninstall({ names: ['ext1', 'ext2'] }); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Failed to uninstall "ext1": Extension not found', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Failed to uninstall "ext2": Extension not found', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); mockCwd.mockRestore(); }); it('should log an error message and exit with code 1 when initialization fails', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => {}) as ( code?: string | number | null | undefined, ) => never); const error = new Error('Initialization failed'); mockLoadExtensions.mockRejectedValue(error); mockGetErrorMessage.mockReturnValue('Initialization failed message'); await handleUninstall({ names: ['my-extension'] }); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Initialization failed message', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); mockCwd.mockRestore(); }); }); describe('uninstallCommand', () => { const command = uninstallCommand; it('should have correct command and describe', () => { expect(command.command).toBe('uninstall [names..]'); expect(command.describe).toBe('Uninstalls one or more extensions.'); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure arguments and options', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); expect(yargsMock.positional).toHaveBeenCalledWith('names', { describe: 'The name(s) or source path(s) of the extension(s) to uninstall.', type: 'string', array: true, }); expect(yargsMock.option).toHaveBeenCalledWith('all', { type: 'boolean', describe: 'Uninstall all installed extensions.', default: false, }); expect(yargsMock.check).toHaveBeenCalled(); }); it('check function should throw for missing names and no --all flag', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(() => checkCallback({ names: [], all: false })).toThrow( 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', ); }); it('check function should pass if --all flag is used even without names', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(() => checkCallback({ names: [], all: true })).not.toThrow(); }); }); it('handler should call handleUninstall', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { names?: string[]; all?: boolean; _: string[]; $0: string; } const argv: TestArgv = { names: ['my-extension'], all: false, _: [], $0: '', }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); expect(mockUninstallExtension).toHaveBeenCalledWith( 'my-extension', false, ); mockCwd.mockRestore(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/uninstall.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface UninstallArgs { names?: string[]; // can be extension names or source URLs. all?: boolean; } export async function handleUninstall(args: UninstallArgs) { try { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); let namesToUninstall: string[] = []; if (args.all) { namesToUninstall = extensionManager .getExtensions() .map((ext) => ext.name); } else if (args.names) { namesToUninstall = [...new Set(args.names)]; } if (namesToUninstall.length === 0) { if (args.all) { debugLogger.log('No extensions currently installed.'); } return; } const errors: Array<{ name: string; error: string }> = []; for (const name of namesToUninstall) { try { await extensionManager.uninstallExtension(name, false); debugLogger.log(`Extension "${name}" successfully uninstalled.`); } catch (error) { errors.push({ name, error: getErrorMessage(error) }); } } if (errors.length > 0) { for (const { name, error } of errors) { debugLogger.error(`Failed to uninstall "${name}": ${error}`); } process.exit(1); } } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } export const uninstallCommand: CommandModule = { command: 'uninstall [names..]', describe: 'Uninstalls one or more extensions.', builder: (yargs) => yargs .positional('names', { describe: 'The name(s) or source path(s) of the extension(s) to uninstall.', type: 'string', array: true, }) .option('all', { type: 'boolean', describe: 'Uninstall all installed extensions.', default: false, }) .check((argv) => { if (!argv.all && (!argv.names || argv.names.length === 0)) { throw new Error( 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', ); } return true; }), handler: async (argv) => { await handleUninstall({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion names: argv['names'] as string[] | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion all: argv['all'] as boolean, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/update.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { format } from 'node:util'; import { type Argv } from 'yargs'; import { handleUpdate, updateCommand } from './update.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import * as update from '../../config/extensions/update.js'; import * as github from '../../config/extensions/github.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); const emitFeedback = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); }), error: vi.fn((message, ...args) => { emitConsoleLog('error', format(message, ...args)); }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitConsoleLog, emitFeedback, }, debugLogger, }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../utils/errors.js'); vi.mock('../../config/extensions/update.js'); vi.mock('../../config/extensions/github.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions update command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockExtensionManager = vi.mocked(ExtensionManager); const mockUpdateExtension = vi.mocked(update.updateExtension); const mockCheckForExtensionUpdate = vi.mocked(github.checkForExtensionUpdate); const mockCheckForAllExtensionUpdates = vi.mocked( update.checkForAllExtensionUpdates, ); const mockUpdateAllUpdatableExtensions = vi.mocked( update.updateAllUpdatableExtensions, ); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: { experimental: { extensionReloading: true } }, } as unknown as LoadedSettings); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleUpdate', () => { it('should list installed extensions when requested extension is not found', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const extensions = [ { name: 'ext1', version: '1.0.0' }, { name: 'ext2', version: '2.0.0' }, ]; mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(extensions); await handleUpdate({ name: 'missing-extension' }); expect(emitFeedback).toHaveBeenCalledWith( 'error', 'Extension "missing-extension" not found.\n\nInstalled extensions:\next1 (1.0.0)\next2 (2.0.0)\n\nRun "gemini extensions list" for details.', ); expect(mockUpdateExtension).not.toHaveBeenCalled(); mockCwd.mockRestore(); }); it('should log a helpful message when no extensions are installed and requested extension is not found', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); await handleUpdate({ name: 'missing-extension' }); expect(emitFeedback).toHaveBeenCalledWith( 'error', 'Extension "missing-extension" not found.\n\nNo extensions installed.', ); expect(mockUpdateExtension).not.toHaveBeenCalled(); mockCwd.mockRestore(); }); it.each([ { state: ExtensionUpdateState.UPDATE_AVAILABLE, expectedLog: 'Extension "my-extension" successfully updated: 1.0.0 → 1.1.0.', shouldCallUpdateExtension: true, }, { state: ExtensionUpdateState.UP_TO_DATE, expectedLog: 'Extension "my-extension" is already up to date.', shouldCallUpdateExtension: false, }, ])( 'should handle single extension update state: $state', async ({ state, expectedLog, shouldCallUpdateExtension }) => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const extensions = [{ name: 'my-extension', installMetadata: {} }]; mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(extensions); mockCheckForExtensionUpdate.mockResolvedValue(state); mockUpdateExtension.mockResolvedValue({ name: 'my-extension', originalVersion: '1.0.0', updatedVersion: '1.1.0', }); await handleUpdate({ name: 'my-extension' }); expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); if (shouldCallUpdateExtension) { expect(mockUpdateExtension).toHaveBeenCalled(); } else { expect(mockUpdateExtension).not.toHaveBeenCalled(); } mockCwd.mockRestore(); }, ); it.each([ { updatedExtensions: [ { name: 'ext1', originalVersion: '1.0.0', updatedVersion: '1.1.0' }, { name: 'ext2', originalVersion: '2.0.0', updatedVersion: '2.1.0' }, ], expectedLog: 'Extension "ext1" successfully updated: 1.0.0 → 1.1.0.\nExtension "ext2" successfully updated: 2.0.0 → 2.1.0.', }, { updatedExtensions: [], expectedLog: 'No extensions to update.', }, ])( 'should handle updating all extensions: %s', async ({ updatedExtensions, expectedLog }) => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); mockCheckForAllExtensionUpdates.mockResolvedValue(undefined); mockUpdateAllUpdatableExtensions.mockResolvedValue(updatedExtensions); await handleUpdate({ all: true }); expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog); mockCwd.mockRestore(); }, ); }); describe('updateCommand', () => { const command = updateCommand; it('should have correct command and describe', () => { expect(command.command).toBe('update [] [--all]'); expect(command.describe).toBe( 'Updates all extensions or a named extension to the latest version.', ); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; conflicts: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), conflicts: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure arguments', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); expect(yargsMock.positional).toHaveBeenCalledWith( 'name', expect.any(Object), ); expect(yargsMock.option).toHaveBeenCalledWith( 'all', expect.any(Object), ); expect(yargsMock.conflicts).toHaveBeenCalledWith('name', 'all'); expect(yargsMock.check).toHaveBeenCalled(); }); it('check function should throw an error if neither a name nor --all is provided', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(() => checkCallback({ name: undefined, all: false })).toThrow( 'Either an extension name or --all must be provided', ); }); }); it('handler should call handleUpdate', async () => { const extensions = [{ name: 'my-extension', installMetadata: {} }]; mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(extensions); mockCheckForExtensionUpdate.mockResolvedValue( ExtensionUpdateState.UPDATE_AVAILABLE, ); mockUpdateExtension.mockResolvedValue({ name: 'my-extension', originalVersion: '1.0.0', updatedVersion: '1.1.0', }); await (command.handler as (args: object) => Promise)({ name: 'my-extension', }); expect(mockUpdateExtension).toHaveBeenCalled(); }); }); }); ================================================ FILE: packages/cli/src/commands/extensions/update.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { updateAllUpdatableExtensions, type ExtensionUpdateInfo, checkForAllExtensionUpdates, updateExtension, } from '../../config/extensions/update.js'; import { checkForExtensionUpdate } from '../../config/extensions/github.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { coreEvents, debugLogger, getErrorMessage, } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface UpdateArgs { name?: string; all?: boolean; } const updateOutput = (info: ExtensionUpdateInfo) => `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; export async function handleUpdate(args: UpdateArgs) { const workspaceDir = process.cwd(); const settings = loadSettings(workspaceDir).merged; const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings, }); const extensions = await extensionManager.loadExtensions(); if (args.name) { try { const extension = extensions.find( (extension) => extension.name === args.name, ); if (!extension) { if (extensions.length === 0) { coreEvents.emitFeedback( 'error', `Extension "${args.name}" not found.\n\nNo extensions installed.`, ); return; } const installedExtensions = extensions .map((extension) => `${extension.name} (${extension.version})`) .join('\n'); coreEvents.emitFeedback( 'error', `Extension "${args.name}" not found.\n\nInstalled extensions:\n${installedExtensions}\n\nRun "gemini extensions list" for details.`, ); return; } if (!extension.installMetadata) { debugLogger.log( `Unable to install extension "${args.name}" due to missing install metadata`, ); return; } const updateState = await checkForExtensionUpdate( extension, extensionManager, ); if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { debugLogger.log(`Extension "${args.name}" is already up to date.`); return; } const updatedExtensionInfo = (await updateExtension( extension, extensionManager, updateState, () => {}, settings.experimental?.extensionReloading, ))!; if ( updatedExtensionInfo.originalVersion !== updatedExtensionInfo.updatedVersion ) { debugLogger.log( `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, ); } else { debugLogger.log(`Extension "${args.name}" is already up to date.`); } } catch (error) { debugLogger.error(getErrorMessage(error)); } } if (args.all) { try { const extensionState = new Map(); await checkForAllExtensionUpdates( extensions, extensionManager, (action) => { if (action.type === 'SET_STATE') { extensionState.set(action.payload.name, { status: action.payload.state, }); } }, ); let updateInfos = await updateAllUpdatableExtensions( extensions, extensionState, extensionManager, () => {}, ); updateInfos = updateInfos.filter( (info) => info.originalVersion !== info.updatedVersion, ); if (updateInfos.length === 0) { debugLogger.log('No extensions to update.'); return; } debugLogger.log(updateInfos.map((info) => updateOutput(info)).join('\n')); } catch (error) { debugLogger.error(getErrorMessage(error)); } } } export const updateCommand: CommandModule = { command: 'update [] [--all]', describe: 'Updates all extensions or a named extension to the latest version.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the extension to update.', type: 'string', }) .option('all', { describe: 'Update all extensions.', type: 'boolean', }) .conflicts('name', 'all') .check((argv) => { if (!argv.all && !argv.name) { throw new Error('Either an extension name or --all must be provided'); } return true; }), handler: async (argv) => { await handleUpdate({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion all: argv['all'] as boolean | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions/utils.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { debugLogger, type ResolvedExtensionSetting, } from '@google/gemini-cli-core'; import type { ExtensionConfig } from '../../config/extension.js'; import prompts from 'prompts'; import { promptForSetting, updateSetting, type ExtensionSetting, getScopedEnvContents, ExtensionSettingScope, } from '../../config/extensions/extensionSettings.js'; export interface ConfigLogger { log(message: string): void; error(message: string): void; } export type RequestSettingCallback = ( setting: ExtensionSetting, ) => Promise; export type RequestConfirmationCallback = (message: string) => Promise; const defaultLogger: ConfigLogger = { log: (message: string) => debugLogger.log(message), error: (message: string) => debugLogger.error(message), }; const defaultRequestSetting: RequestSettingCallback = async (setting) => promptForSetting(setting); const defaultRequestConfirmation: RequestConfirmationCallback = async ( message, ) => { const response = await prompts({ type: 'confirm', name: 'confirm', message, initial: false, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.confirm; }; export async function getExtensionManager() { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); return extensionManager; } export async function getExtensionAndManager( extensionManager: ExtensionManager, name: string, logger: ConfigLogger = defaultLogger, ) { const extension = extensionManager .getExtensions() .find((ext) => ext.name === name); if (!extension) { logger.error(`Extension "${name}" is not installed.`); return { extension: null }; } return { extension }; } export async function configureSpecificSetting( extensionManager: ExtensionManager, extensionName: string, settingKey: string, scope: ExtensionSettingScope, logger: ConfigLogger = defaultLogger, requestSetting: RequestSettingCallback = defaultRequestSetting, ) { const { extension } = await getExtensionAndManager( extensionManager, extensionName, logger, ); if (!extension) { return; } const extensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); if (!extensionConfig) { logger.error( `Could not find configuration for extension "${extensionName}".`, ); return; } await updateSetting( extensionConfig, extension.id, settingKey, requestSetting, scope, process.cwd(), ); logger.log(`Setting "${settingKey}" updated.`); } export async function configureExtension( extensionManager: ExtensionManager, extensionName: string, scope: ExtensionSettingScope, logger: ConfigLogger = defaultLogger, requestSetting: RequestSettingCallback = defaultRequestSetting, requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, ) { const { extension } = await getExtensionAndManager( extensionManager, extensionName, logger, ); if (!extension) { return; } const extensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); if ( !extensionConfig || !extensionConfig.settings || extensionConfig.settings.length === 0 ) { logger.log(`Extension "${extensionName}" has no settings to configure.`); return; } logger.log(`Configuring settings for "${extensionName}"...`); await configureExtensionSettings( extensionConfig, extension.id, scope, logger, requestSetting, requestConfirmation, ); } export async function configureAllExtensions( extensionManager: ExtensionManager, scope: ExtensionSettingScope, logger: ConfigLogger = defaultLogger, requestSetting: RequestSettingCallback = defaultRequestSetting, requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, ) { const extensions = extensionManager.getExtensions(); if (extensions.length === 0) { logger.log('No extensions installed.'); return; } for (const extension of extensions) { const extensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); if ( extensionConfig && extensionConfig.settings && extensionConfig.settings.length > 0 ) { logger.log(`\nConfiguring settings for "${extension.name}"...`); await configureExtensionSettings( extensionConfig, extension.id, scope, logger, requestSetting, requestConfirmation, ); } } } export async function configureExtensionSettings( extensionConfig: ExtensionConfig, extensionId: string, scope: ExtensionSettingScope, logger: ConfigLogger = defaultLogger, requestSetting: RequestSettingCallback = defaultRequestSetting, requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation, ) { const currentScopedSettings = await getScopedEnvContents( extensionConfig, extensionId, scope, process.cwd(), ); let workspaceSettings: Record = {}; if (scope === ExtensionSettingScope.USER) { workspaceSettings = await getScopedEnvContents( extensionConfig, extensionId, ExtensionSettingScope.WORKSPACE, process.cwd(), ); } if (!extensionConfig.settings) return; for (const setting of extensionConfig.settings) { const currentValue = currentScopedSettings[setting.envVar]; const workspaceValue = workspaceSettings[setting.envVar]; if (workspaceValue !== undefined) { logger.log( `Note: Setting "${setting.name}" is already configured in the workspace scope.`, ); } if (currentValue !== undefined) { const confirmed = await requestConfirmation( `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`, ); if (!confirmed) { continue; } } await updateSetting( extensionConfig, extensionId, setting.envVar, requestSetting, scope, process.cwd(), ); } } export function getFormattedSettingValue( setting: ResolvedExtensionSetting, ): string { if (!setting.value) { return '[not set]'; } if (setting.sensitive) { return '***'; } return setting.value; } ================================================ FILE: packages/cli/src/commands/extensions/validate.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance, } from 'vitest'; import { handleValidate, validateCommand } from './validate.js'; import yargs from 'yargs'; import { createExtension } from '../../test-utils/createExtension.js'; import path from 'node:path'; import * as os from 'node:os'; import { debugLogger } from '@google/gemini-cli-core'; vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions validate command', () => { it('should fail if no path is provided', () => { const validationParser = yargs([]).command(validateCommand).fail(false); expect(() => validationParser.parse('validate')).toThrow( 'Not enough non-option arguments: got 0, need at least 1', ); }); }); describe('handleValidate', () => { let debugLoggerLogSpy: MockInstance; let debugLoggerWarnSpy: MockInstance; let debugLoggerErrorSpy: MockInstance; let processSpy: MockInstance; let tempHomeDir: string; let tempWorkspaceDir: string; beforeEach(() => { debugLoggerLogSpy = vi.spyOn(debugLogger, 'log'); debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn'); debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home')); tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace')); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); }); afterEach(() => { vi.restoreAllMocks(); fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); }); it('should validate an extension from a local dir', async () => { createExtension({ extensionsDir: tempWorkspaceDir, name: 'local-ext-name', version: '1.0.0', }); await handleValidate({ path: 'local-ext-name', }); expect(debugLoggerLogSpy).toHaveBeenCalledWith( 'Extension local-ext-name has been successfully validated.', ); }); it('should throw an error if the extension name is invalid', async () => { createExtension({ extensionsDir: tempWorkspaceDir, name: 'INVALID_NAME', version: '1.0.0', }); await handleValidate({ path: 'INVALID_NAME', }); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining( 'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.', ), ); expect(processSpy).toHaveBeenCalledWith(1); }); it('should warn if version is not formatted with semver', async () => { createExtension({ extensionsDir: tempWorkspaceDir, name: 'valid-name', version: '1', }); await handleValidate({ path: 'valid-name', }); expect(debugLoggerWarnSpy).toHaveBeenCalledWith( expect.stringContaining( "Version '1' does not appear to be standard semver (e.g., 1.0.0).", ), ); expect(debugLoggerLogSpy).toHaveBeenCalledWith( 'Extension valid-name has been successfully validated.', ); }); it('should throw an error if context files are missing', async () => { createExtension({ extensionsDir: tempWorkspaceDir, name: 'valid-name', version: '1.0.0', contextFileName: 'contextFile.md', }); fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md')); await handleValidate({ path: 'valid-name', }); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining( 'The following context files referenced in gemini-extension.json are missing: contextFile.md', ), ); expect(processSpy).toHaveBeenCalledWith(1); }); }); ================================================ FILE: packages/cli/src/commands/extensions/validate.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import semver from 'semver'; import type { ExtensionConfig } from '../../config/extension.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { loadSettings } from '../../config/settings.js'; import { exitCli } from '../utils.js'; interface ValidateArgs { path: string; } export async function handleValidate(args: ValidateArgs) { try { await validateExtension(args); debugLogger.log(`Extension ${args.path} has been successfully validated.`); } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); } } async function validateExtension(args: ValidateArgs) { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, settings: loadSettings(workspaceDir).merged, }); const absoluteInputPath = path.resolve(args.path); const extensionConfig: ExtensionConfig = await extensionManager.loadExtensionConfig(absoluteInputPath); const warnings: string[] = []; const errors: string[] = []; if (extensionConfig.contextFileName) { const contextFileNames = Array.isArray(extensionConfig.contextFileName) ? extensionConfig.contextFileName : [extensionConfig.contextFileName]; const missingContextFiles: string[] = []; for (const contextFilePath of contextFileNames) { const contextFileAbsolutePath = path.resolve( absoluteInputPath, contextFilePath, ); if (!fs.existsSync(contextFileAbsolutePath)) { missingContextFiles.push(contextFilePath); } } if (missingContextFiles.length > 0) { errors.push( `The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`, ); } } if (!semver.valid(extensionConfig.version)) { warnings.push( `Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`, ); } if (warnings.length > 0) { debugLogger.warn('Validation warnings:'); for (const warning of warnings) { debugLogger.warn(` - ${warning}`); } } if (errors.length > 0) { debugLogger.error('Validation failed with the following errors:'); for (const error of errors) { debugLogger.error(` - ${error}`); } throw new Error('Extension validation failed.'); } } export const validateCommand: CommandModule = { command: 'validate ', describe: 'Validates an extension from a local path.', builder: (yargs) => yargs.positional('path', { describe: 'The path of the extension to validate.', type: 'string', demandOption: true, }), handler: async (args) => { await handleValidate({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: args['path'] as string, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/extensions.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { extensionsCommand } from './extensions.js'; // Mock subcommands vi.mock('./extensions/install.js', () => ({ installCommand: { command: 'install' }, })); vi.mock('./extensions/uninstall.js', () => ({ uninstallCommand: { command: 'uninstall' }, })); vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } })); vi.mock('./extensions/update.js', () => ({ updateCommand: { command: 'update' }, })); vi.mock('./extensions/disable.js', () => ({ disableCommand: { command: 'disable' }, })); vi.mock('./extensions/enable.js', () => ({ enableCommand: { command: 'enable' }, })); vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } })); vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } })); vi.mock('./extensions/validate.js', () => ({ validateCommand: { command: 'validate' }, })); // Mock gemini.js vi.mock('../gemini.js', () => ({ initializeOutputListenersAndFlush: vi.fn(), })); describe('extensionsCommand', () => { it('should have correct command and aliases', () => { expect(extensionsCommand.command).toBe('extensions '); expect(extensionsCommand.aliases).toEqual(['extension']); expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.'); }); it('should register all subcommands in builder', () => { const mockYargs = { middleware: vi.fn().mockReturnThis(), command: vi.fn().mockReturnThis(), demandCommand: vi.fn().mockReturnThis(), version: vi.fn().mockReturnThis(), }; // @ts-expect-error - Mocking yargs extensionsCommand.builder(mockYargs); expect(mockYargs.middleware).toHaveBeenCalled(); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'install' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'uninstall' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'list' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'update' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'disable' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'enable' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'link' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'new' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'validate' }), ); expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); expect(mockYargs.version).toHaveBeenCalledWith(false); }); it('should have a handler that does nothing', () => { // @ts-expect-error - Handler doesn't take arguments in this case expect(extensionsCommand.handler()).toBeUndefined(); }); }); ================================================ FILE: packages/cli/src/commands/extensions.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { installCommand } from './extensions/install.js'; import { uninstallCommand } from './extensions/uninstall.js'; import { listCommand } from './extensions/list.js'; import { updateCommand } from './extensions/update.js'; import { disableCommand } from './extensions/disable.js'; import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { validateCommand } from './extensions/validate.js'; import { configureCommand } from './extensions/configure.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', aliases: ['extension'], describe: 'Manage Gemini CLI extensions.', builder: (yargs) => yargs .middleware((argv) => { initializeOutputListenersAndFlush(); argv['isCommand'] = true; }) .command(defer(installCommand, 'extensions')) .command(defer(uninstallCommand, 'extensions')) .command(defer(listCommand, 'extensions')) .command(defer(updateCommand, 'extensions')) .command(defer(disableCommand, 'extensions')) .command(defer(enableCommand, 'extensions')) .command(defer(linkCommand, 'extensions')) .command(defer(newCommand, 'extensions')) .command(defer(validateCommand, 'extensions')) .command(defer(configureCommand, 'extensions')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { // This handler is not called when a subcommand is provided. // Yargs will show the help menu. }, }; ================================================ FILE: packages/cli/src/commands/hooks/migrate.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, type MockInstance, } from 'vitest'; import * as fs from 'node:fs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; import { handleMigrateFromClaude } from './migrate.js'; vi.mock('node:fs'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); vi.mock('../../config/settings.js', async () => { const actual = await vi.importActual('../../config/settings.js'); return { ...actual, loadSettings: vi.fn(), }; }); const mockedLoadSettings = loadSettings as Mock; const mockedFs = vi.mocked(fs); describe('migrate command', () => { let mockSetValue: Mock; let debugLoggerLogSpy: MockInstance; let debugLoggerErrorSpy: MockInstance; let originalCwd: () => string; beforeEach(() => { vi.resetAllMocks(); mockSetValue = vi.fn(); debugLoggerLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); // Mock process.cwd() originalCwd = process.cwd; process.cwd = vi.fn(() => '/test/project'); mockedLoadSettings.mockReturnValue({ merged: { hooks: {}, }, setValue: mockSetValue, workspace: { path: '/test/project/.gemini' }, }); }); afterEach(() => { process.cwd = originalCwd; vi.restoreAllMocks(); }); it('should log error when no Claude settings files exist', async () => { mockedFs.existsSync.mockReturnValue(false); await handleMigrateFromClaude(); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', ); expect(mockSetValue).not.toHaveBeenCalled(); }); it('should migrate hooks from settings.json when it exists', async () => { const claudeSettings = { hooks: { PreToolUse: [ { matcher: 'Edit', hooks: [ { type: 'command', command: 'echo "Before Edit"', timeout: 30, }, ], }, ], }, }; mockedFs.existsSync.mockImplementation((path) => path.toString().endsWith('settings.json'), ); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'hooks', expect.objectContaining({ BeforeTool: expect.arrayContaining([ expect.objectContaining({ matcher: 'replace', hooks: expect.arrayContaining([ expect.objectContaining({ command: 'echo "Before Edit"', type: 'command', timeout: 30, }), ]), }), ]), }), ); expect(debugLoggerLogSpy).toHaveBeenCalledWith( expect.stringContaining('Found Claude Code settings'), ); expect(debugLoggerLogSpy).toHaveBeenCalledWith( expect.stringContaining('Migrating 1 hook event'), ); expect(debugLoggerLogSpy).toHaveBeenCalledWith( '✓ Hooks successfully migrated to .gemini/settings.json', ); }); it('should prefer settings.local.json over settings.json', async () => { const localSettings = { hooks: { SessionStart: [ { hooks: [ { type: 'command', command: 'echo "Local session start"', }, ], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(localSettings)); await handleMigrateFromClaude(); expect(mockedFs.readFileSync).toHaveBeenCalledWith( expect.stringContaining('settings.local.json'), 'utf-8', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'hooks', expect.objectContaining({ SessionStart: expect.any(Array), }), ); }); it('should migrate all supported event types', async () => { const claudeSettings = { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo 1' }] }], PostToolUse: [{ hooks: [{ type: 'command', command: 'echo 2' }] }], UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo 3' }] }], Stop: [{ hooks: [{ type: 'command', command: 'echo 4' }] }], SubAgentStop: [{ hooks: [{ type: 'command', command: 'echo 5' }] }], SessionStart: [{ hooks: [{ type: 'command', command: 'echo 6' }] }], SessionEnd: [{ hooks: [{ type: 'command', command: 'echo 7' }] }], PreCompact: [{ hooks: [{ type: 'command', command: 'echo 8' }] }], Notification: [{ hooks: [{ type: 'command', command: 'echo 9' }] }], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks).toHaveProperty('BeforeTool'); expect(migratedHooks).toHaveProperty('AfterTool'); expect(migratedHooks).toHaveProperty('BeforeAgent'); expect(migratedHooks).toHaveProperty('AfterAgent'); expect(migratedHooks).toHaveProperty('SessionStart'); expect(migratedHooks).toHaveProperty('SessionEnd'); expect(migratedHooks).toHaveProperty('PreCompress'); expect(migratedHooks).toHaveProperty('Notification'); }); it('should transform tool names in matchers', async () => { const claudeSettings = { hooks: { PreToolUse: [ { matcher: 'Edit|Bash|Read|Write|Glob|Grep', hooks: [{ type: 'command', command: 'echo "test"' }], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].matcher).toBe( 'replace|run_shell_command|read_file|write_file|glob|grep', ); }); it('should replace $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [ { type: 'command', command: 'cd $CLAUDE_PROJECT_DIR && ls', }, ], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe( 'cd $GEMINI_PROJECT_DIR && ls', ); }); it('should preserve sequential flag', async () => { const claudeSettings = { hooks: { PreToolUse: [ { sequential: true, hooks: [{ type: 'command', command: 'echo "test"' }], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].sequential).toBe(true); }); it('should preserve timeout values', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [ { type: 'command', command: 'echo "test"', timeout: 60, }, ], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].hooks[0].timeout).toBe(60); }); it('should merge with existing Gemini hooks', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [{ type: 'command', command: 'echo "claude"' }], }, ], }, }; mockedLoadSettings.mockReturnValue({ merged: { hooks: { AfterTool: [ { hooks: [{ type: 'command', command: 'echo "existing"' }], }, ], }, }, setValue: mockSetValue, workspace: { path: '/test/project/.gemini' }, }); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks).toHaveProperty('BeforeTool'); expect(migratedHooks).toHaveProperty('AfterTool'); expect(migratedHooks.AfterTool[0].hooks[0].command).toBe('echo "existing"'); expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe('echo "claude"'); }); it('should handle JSON with comments', async () => { const claudeSettingsWithComments = `{ // This is a comment "hooks": { /* Block comment */ "PreToolUse": [ { "hooks": [ { "type": "command", "command": "echo test" // Inline comment } ] } ] } }`; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(claudeSettingsWithComments); await handleMigrateFromClaude(); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'hooks', expect.objectContaining({ BeforeTool: expect.any(Array), }), ); }); it('should handle malformed JSON gracefully', async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue('{ invalid json }'); await handleMigrateFromClaude(); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Error reading'), ); expect(mockSetValue).not.toHaveBeenCalled(); }); it('should log info when no hooks are found in Claude settings', async () => { const claudeSettings = { someOtherSetting: 'value', }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); expect(debugLoggerLogSpy).toHaveBeenCalledWith( 'No hooks found in Claude Code settings to migrate.', ); expect(mockSetValue).not.toHaveBeenCalled(); }); it('should handle setValue errors gracefully', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [{ type: 'command', command: 'echo "test"' }], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); mockSetValue.mockImplementation(() => { throw new Error('Failed to save'); }); await handleMigrateFromClaude(); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'Error saving migrated hooks: Failed to save', ); }); it('should handle hooks with matcher but no command', async () => { const claudeSettings = { hooks: { PreToolUse: [ { matcher: 'Edit', hooks: [ { type: 'command', }, ], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].matcher).toBe('replace'); expect(migratedHooks.BeforeTool[0].hooks[0].type).toBe('command'); }); it('should handle empty hooks array', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks.BeforeTool[0].hooks).toEqual([]); }); it('should handle non-array event config gracefully', async () => { const claudeSettings = { hooks: { PreToolUse: 'not an array', PostToolUse: [ { hooks: [{ type: 'command', command: 'echo "test"' }], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); const migratedHooks = mockSetValue.mock.calls[0][2]; expect(migratedHooks).not.toHaveProperty('BeforeTool'); expect(migratedHooks).toHaveProperty('AfterTool'); }); it('should display migration instructions after successful migration', async () => { const claudeSettings = { hooks: { PreToolUse: [ { hooks: [{ type: 'command', command: 'echo "test"' }], }, ], }, }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings)); await handleMigrateFromClaude(); expect(debugLoggerLogSpy).toHaveBeenCalledWith( '✓ Hooks successfully migrated to .gemini/settings.json', ); expect(debugLoggerLogSpy).toHaveBeenCalledWith( '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', ); }); }); ================================================ FILE: packages/cli/src/commands/hooks/migrate.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { exitCli } from '../utils.js'; import stripJsonComments from 'strip-json-comments'; interface MigrateArgs { fromClaude: boolean; } /** * Mapping from Claude Code event names to Gemini event names */ const EVENT_MAPPING: Record = { PreToolUse: 'BeforeTool', PostToolUse: 'AfterTool', UserPromptSubmit: 'BeforeAgent', Stop: 'AfterAgent', SubAgentStop: 'AfterAgent', // Gemini doesn't have sub-agents, map to AfterAgent SessionStart: 'SessionStart', SessionEnd: 'SessionEnd', PreCompact: 'PreCompress', Notification: 'Notification', }; /** * Mapping from Claude Code tool names to Gemini tool names */ const TOOL_NAME_MAPPING: Record = { Edit: 'replace', Bash: 'run_shell_command', Read: 'read_file', Write: 'write_file', Glob: 'glob', Grep: 'grep', LS: 'ls', }; /** * Transform a matcher regex to update tool names from Claude to Gemini */ function transformMatcher(matcher: string | undefined): string | undefined { if (!matcher) return matcher; let transformed = matcher; for (const [claudeName, geminiName] of Object.entries(TOOL_NAME_MAPPING)) { // Replace exact matches and matches within regex alternations transformed = transformed.replace( new RegExp(`\\b${claudeName}\\b`, 'g'), geminiName, ); } return transformed; } /** * Migrate a Claude Code hook configuration to Gemini format */ function migrateClaudeHook(claudeHook: unknown): unknown { if (!claudeHook || typeof claudeHook !== 'object') { return claudeHook; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const hook = claudeHook as Record; const migrated: Record = {}; // Map command field if ('command' in hook) { migrated['command'] = hook['command']; // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command // eslint-disable-next-line no-restricted-syntax if (typeof migrated['command'] === 'string') { migrated['command'] = migrated['command'].replace( /\$CLAUDE_PROJECT_DIR/g, '$GEMINI_PROJECT_DIR', ); } } // Map type field if ('type' in hook && hook['type'] === 'command') { migrated['type'] = 'command'; } // Map timeout field (Claude uses seconds, Gemini uses seconds) // eslint-disable-next-line no-restricted-syntax if ('timeout' in hook && typeof hook['timeout'] === 'number') { migrated['timeout'] = hook['timeout']; } return migrated; } /** * Migrate Claude Code hooks configuration to Gemini format */ function migrateClaudeHooks(claudeConfig: unknown): Record { if (!claudeConfig || typeof claudeConfig !== 'object') { return {}; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = claudeConfig as Record; const geminiHooks: Record = {}; // Check if there's a hooks section // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const hooksSection = config['hooks'] as Record | undefined; if (!hooksSection || typeof hooksSection !== 'object') { return {}; } for (const [eventName, eventConfig] of Object.entries(hooksSection)) { // Map event name const geminiEventName = EVENT_MAPPING[eventName] || eventName; if (!Array.isArray(eventConfig)) { continue; } // Migrate each hook definition const migratedDefinitions = eventConfig.map((def: unknown) => { if (!def || typeof def !== 'object') { return def; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const definition = def as Record; const migratedDef: Record = {}; // Transform matcher if ( 'matcher' in definition && // eslint-disable-next-line no-restricted-syntax typeof definition['matcher'] === 'string' ) { migratedDef['matcher'] = transformMatcher(definition['matcher']); } // Copy sequential flag if ('sequential' in definition) { migratedDef['sequential'] = definition['sequential']; } // Migrate hooks array if ('hooks' in definition && Array.isArray(definition['hooks'])) { migratedDef['hooks'] = definition['hooks'].map(migrateClaudeHook); } return migratedDef; }); geminiHooks[geminiEventName] = migratedDefinitions; } return geminiHooks; } /** * Handle migration from Claude Code */ export async function handleMigrateFromClaude() { const workingDir = process.cwd(); // Look for Claude settings in .claude directory const claudeDir = path.join(workingDir, '.claude'); const claudeSettingsPath = path.join(claudeDir, 'settings.json'); const claudeLocalSettingsPath = path.join(claudeDir, 'settings.local.json'); let claudeSettings: Record | null = null; let sourceFile = ''; // Try to read settings.local.json first, then settings.json if (fs.existsSync(claudeLocalSettingsPath)) { sourceFile = claudeLocalSettingsPath; try { const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion claudeSettings = JSON.parse(stripJsonComments(content)) as Record< string, unknown >; } catch (error) { debugLogger.error( `Error reading ${claudeLocalSettingsPath}: ${getErrorMessage(error)}`, ); } } else if (fs.existsSync(claudeSettingsPath)) { sourceFile = claudeSettingsPath; try { const content = fs.readFileSync(claudeSettingsPath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion claudeSettings = JSON.parse(stripJsonComments(content)) as Record< string, unknown >; } catch (error) { debugLogger.error( `Error reading ${claudeSettingsPath}: ${getErrorMessage(error)}`, ); } } else { debugLogger.error( 'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json', ); return; } if (!claudeSettings) { return; } debugLogger.log(`Found Claude Code settings in: ${sourceFile}`); // Migrate hooks const migratedHooks = migrateClaudeHooks(claudeSettings); if (Object.keys(migratedHooks).length === 0) { debugLogger.log('No hooks found in Claude Code settings to migrate.'); return; } debugLogger.log( `Migrating ${Object.keys(migratedHooks).length} hook event(s)...`, ); // Load current Gemini settings const settings = loadSettings(workingDir); // Merge migrated hooks with existing hooks const existingHooks = (settings.merged?.hooks || {}) as Record< string, unknown >; const mergedHooks = { ...existingHooks, ...migratedHooks }; // Update settings (setValue automatically saves) try { settings.setValue(SettingScope.Workspace, 'hooks', mergedHooks); debugLogger.log('✓ Hooks successfully migrated to .gemini/settings.json'); debugLogger.log( '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', ); } catch (error) { debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`); } } export const migrateCommand: CommandModule = { command: 'migrate', describe: 'Migrate hooks from Claude Code to Gemini CLI', builder: (yargs) => yargs.option('from-claude', { describe: 'Migrate from Claude Code hooks', type: 'boolean', default: false, }), handler: async (argv) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const args = argv as unknown as MigrateArgs; if (args.fromClaude) { await handleMigrateFromClaude(); } else { debugLogger.log( 'Usage: gemini hooks migrate --from-claude\n\nMigrate hooks from Claude Code to Gemini CLI format.', ); } await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/hooks.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { migrateCommand } from './hooks/migrate.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; export const hooksCommand: CommandModule = { command: 'hooks ', aliases: ['hook'], describe: 'Manage Gemini CLI hooks.', builder: (yargs) => yargs .middleware((argv) => { initializeOutputListenersAndFlush(); argv['isCommand'] = true; }) .command(migrateCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { // This handler is not called when a subcommand is provided. // Yargs will show the help menu. }, }; ================================================ FILE: packages/cli/src/commands/mcp/add.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mock, type MockInstance, } from 'vitest'; import yargs, { type Argv } from 'yargs'; import { addCommand } from './add.js'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); vi.mock('fs/promises', () => ({ readFile: vi.fn(), writeFile: vi.fn(), })); vi.mock('os', () => { const homedir = vi.fn(() => '/home/user'); return { default: { homedir, }, homedir, }; }); vi.mock('../../config/settings.js', async () => { const actual = await vi.importActual('../../config/settings.js'); return { ...actual, loadSettings: vi.fn(), }; }); const mockedLoadSettings = loadSettings as Mock; describe('mcp add command', () => { let parser: Argv; let mockSetValue: Mock; let mockConsoleError: Mock; let debugLoggerErrorSpy: MockInstance; beforeEach(() => { vi.resetAllMocks(); const yargsInstance = yargs([]).command(addCommand); parser = yargsInstance; mockSetValue = vi.fn(); mockConsoleError = vi.fn(); debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(mockConsoleError); mockedLoadSettings.mockReturnValue({ forScope: () => ({ settings: {} }), setValue: mockSetValue, workspace: { path: '/path/to/project' }, user: { path: '/home/user' }, }); }); it('should add a stdio server to project settings', async () => { await parser.parseAsync( 'add -e FOO=bar my-server /path/to/server arg1 arg2', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'my-server': { command: '/path/to/server', args: ['arg1', 'arg2'], env: { FOO: 'bar' }, }, }, ); }); it('should handle multiple env vars before positional args', async () => { await parser.parseAsync( 'add -e FOO=bar -e BAZ=qux my-server /path/to/server', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'my-server': { command: '/path/to/server', args: [], env: { FOO: 'bar', BAZ: 'qux' }, }, }, ); }); it('should add an sse server to user settings', async () => { await parser.parseAsync( 'add --transport sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse-endpoint', ); expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'sse-server': { url: 'https://example.com/sse-endpoint', type: 'sse', headers: { 'X-API-Key': 'your-key' }, }, }); }); it('should add an http server to project settings', async () => { await parser.parseAsync( 'add --transport http -H "Authorization: Bearer your-token" http-server https://example.com/mcp', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'http-server': { url: 'https://example.com/mcp', type: 'http', headers: { Authorization: 'Bearer your-token' }, }, }, ); }); it('should add an sse server using --type alias', async () => { await parser.parseAsync( 'add --type sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse', ); expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { 'sse-server': { url: 'https://example.com/sse', type: 'sse', headers: { 'X-API-Key': 'your-key' }, }, }); }); it('should add an http server using --type alias', async () => { await parser.parseAsync( 'add --type http -H "Authorization: Bearer your-token" http-server https://example.com/mcp', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'http-server': { url: 'https://example.com/mcp', type: 'http', headers: { Authorization: 'Bearer your-token' }, }, }, ); }); it('should handle MCP server args with -- separator', async () => { await parser.parseAsync( 'add my-server npx -- -y http://example.com/some-package', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'my-server': { command: 'npx', args: ['-y', 'http://example.com/some-package'], }, }, ); }); it('should handle unknown options as MCP server args', async () => { await parser.parseAsync( 'add test-server npx -y http://example.com/some-package', ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', { 'test-server': { command: 'npx', args: ['-y', 'http://example.com/some-package'], }, }, ); }); describe('when handling scope and directory', () => { const serverName = 'test-server'; const command = 'echo'; const setupMocks = (cwd: string, workspacePath: string) => { vi.spyOn(process, 'cwd').mockReturnValue(cwd); mockedLoadSettings.mockReturnValue({ forScope: () => ({ settings: {} }), setValue: mockSetValue, workspace: { path: workspacePath }, user: { path: '/home/user' }, }); }; describe('when in a project directory', () => { beforeEach(() => { setupMocks('/path/to/project', '/path/to/project'); }); it('should use project scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), ); }); it('should use project scope when --scope=project is used', async () => { await parser.parseAsync(`add --scope project ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), ); }); it('should use user scope when --scope=user is used', async () => { await parser.parseAsync(`add --scope user ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), ); }); }); describe('when in a subdirectory of a project', () => { beforeEach(() => { setupMocks('/path/to/project/subdir', '/path/to/project'); }); it('should use project scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), ); }); }); describe('when in the home directory', () => { beforeEach(() => { setupMocks('/home/user', '/home/user'); }); it('should show an error by default', async () => { const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => { throw new Error('process.exit called'); }) as (code?: number | string | null) => never); await expect( parser.parseAsync(`add ${serverName} ${command}`), ).rejects.toThrow('process.exit called'); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'Error: Please use --scope user to edit settings in the home directory.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); expect(mockSetValue).not.toHaveBeenCalled(); }); it('should show an error when --scope=project is used explicitly', async () => { const mockProcessExit = vi .spyOn(process, 'exit') .mockImplementation((() => { throw new Error('process.exit called'); }) as (code?: number | string | null) => never); await expect( parser.parseAsync(`add --scope project ${serverName} ${command}`), ).rejects.toThrow('process.exit called'); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'Error: Please use --scope user to edit settings in the home directory.', ); expect(mockProcessExit).toHaveBeenCalledWith(1); expect(mockSetValue).not.toHaveBeenCalled(); }); it('should use user scope when --scope=user is used', async () => { await parser.parseAsync(`add --scope user ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.any(Object), ); expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); }); }); describe('when in a subdirectory of home (not a project)', () => { beforeEach(() => { setupMocks('/home/user/some/dir', '/home/user/some/dir'); }); it('should use project scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), ); }); it('should write to the WORKSPACE scope, not the USER scope', async () => { await parser.parseAsync(`add my-new-server echo`); // We expect setValue to be called once. expect(mockSetValue).toHaveBeenCalledTimes(1); // We get the scope that setValue was called with. const calledScope = mockSetValue.mock.calls[0][0]; // We assert that the scope was Workspace, not User. expect(calledScope).toBe(SettingScope.Workspace); }); }); describe('when outside of home (not a project)', () => { beforeEach(() => { setupMocks('/tmp/foo', '/tmp/foo'); }); it('should use project scope by default', async () => { await parser.parseAsync(`add ${serverName} ${command}`); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.any(Object), ); }); }); }); describe('when updating an existing server', () => { const serverName = 'existing-server'; const initialCommand = 'echo old'; const updatedCommand = 'echo'; const updatedArgs = ['new']; beforeEach(() => { mockedLoadSettings.mockReturnValue({ forScope: () => ({ settings: { mcpServers: { [serverName]: { command: initialCommand, }, }, }, }), setValue: mockSetValue, workspace: { path: '/path/to/project' }, user: { path: '/home/user' }, }); }); it('should update the existing server in the project scope', async () => { await parser.parseAsync( `add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', expect.objectContaining({ [serverName]: expect.objectContaining({ command: updatedCommand, args: updatedArgs, }), }), ); }); it('should update the existing server in the user scope', async () => { await parser.parseAsync( `add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`, ); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.User, 'mcpServers', expect.objectContaining({ [serverName]: expect.objectContaining({ command: updatedCommand, args: updatedArgs, }), }), ); }); }); }); ================================================ FILE: packages/cli/src/commands/mcp/add.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // File for 'gemini mcp add' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; async function addMcpServer( name: string, commandOrUrl: string, args: Array | undefined, options: { scope: string; transport: string; env: string[] | undefined; header: string[] | undefined; timeout?: number; trust?: boolean; description?: string; includeTools?: string[]; excludeTools?: string[]; }, ) { const { scope, transport, env, header, timeout, trust, description, includeTools, excludeTools, } = options; const settings = loadSettings(process.cwd()); const inHome = settings.workspace.path === settings.user.path; if (scope === 'project' && inHome) { debugLogger.error( 'Error: Please use --scope user to edit settings in the home directory.', ); process.exit(1); } const settingsScope = scope === 'user' ? SettingScope.User : SettingScope.Workspace; let newServer: Partial = {}; const headers = header?.reduce( (acc, curr) => { const [key, ...valueParts] = curr.split(':'); const value = valueParts.join(':').trim(); if (key.trim() && value) { acc[key.trim()] = value; } return acc; }, {} as Record, ); switch (transport) { case 'sse': newServer = { url: commandOrUrl, type: 'sse', headers, timeout, trust, description, includeTools, excludeTools, }; break; case 'http': newServer = { url: commandOrUrl, type: 'http', headers, timeout, trust, description, includeTools, excludeTools, }; break; case 'stdio': default: newServer = { command: commandOrUrl, args: args?.map(String), env: env?.reduce( (acc, curr) => { const [key, value] = curr.split('='); if (key && value) { acc[key] = value; } return acc; }, {} as Record, ), timeout, trust, description, includeTools, excludeTools, }; break; } const existingSettings = settings.forScope(settingsScope).settings; const mcpServers = existingSettings.mcpServers || {}; const isExistingServer = !!mcpServers[name]; if (isExistingServer) { debugLogger.log( `MCP server "${name}" is already configured within ${scope} settings.`, ); } mcpServers[name] = newServer as MCPServerConfig; settings.setValue(settingsScope, 'mcpServers', mcpServers); if (isExistingServer) { debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); } else { debugLogger.log( `MCP server "${name}" added to ${scope} settings. (${transport})`, ); } } export const addCommand: CommandModule = { command: 'add [args...]', describe: 'Add a server', builder: (yargs) => yargs .usage('Usage: gemini mcp add [options] [args...]') .parserConfiguration({ 'unknown-options-as-args': true, // Pass unknown options as server args 'populate--': true, // Populate server args after -- separator }) .positional('name', { describe: 'Name of the server', type: 'string', demandOption: true, }) .positional('commandOrUrl', { describe: 'Command (stdio) or URL (sse, http)', type: 'string', demandOption: true, }) .option('scope', { alias: 's', describe: 'Configuration scope (user or project)', type: 'string', default: 'project', choices: ['user', 'project'], }) .option('transport', { alias: ['t', 'type'], describe: 'Transport type (stdio, sse, http)', type: 'string', default: 'stdio', choices: ['stdio', 'sse', 'http'], }) .option('env', { alias: 'e', describe: 'Set environment variables (e.g. -e KEY=value)', type: 'array', string: true, nargs: 1, }) .option('header', { alias: 'H', describe: 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', type: 'array', string: true, nargs: 1, }) .option('timeout', { describe: 'Set connection timeout in milliseconds', type: 'number', }) .option('trust', { describe: 'Trust the server (bypass all tool call confirmation prompts)', type: 'boolean', }) .option('description', { describe: 'Set the description for the server', type: 'string', }) .option('include-tools', { describe: 'A comma-separated list of tools to include', type: 'array', string: true, }) .option('exclude-tools', { describe: 'A comma-separated list of tools to exclude', type: 'array', string: true, }) .middleware((argv) => { // Handle -- separator args as server args if present if (argv['--']) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const existingArgs = (argv['args'] as Array) || []; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['args'] = [...existingArgs, ...(argv['--'] as string[])]; } }), handler: async (argv) => { await addMcpServer( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['name'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['commandOrUrl'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['args'] as Array, { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion transport: argv['transport'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion env: argv['env'] as string[], // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion header: argv['header'] as string[], // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion timeout: argv['timeout'] as number | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion trust: argv['trust'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion description: argv['description'] as string | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion includeTools: argv['includeTools'] as string[] | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion excludeTools: argv['excludeTools'] as string[] | undefined, }, ); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/mcp/enableDisable.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger } from '@google/gemini-cli-core'; import { McpServerEnablementManager, canLoadServer, normalizeServerId, } from '../../config/mcp/mcpServerEnablement.js'; import { loadSettings } from '../../config/settings.js'; import { exitCli } from '../utils.js'; import { getMcpServersFromConfig } from './list.js'; const GREEN = '\x1b[32m'; const YELLOW = '\x1b[33m'; const RED = '\x1b[31m'; const RESET = '\x1b[0m'; interface Args { name: string; session?: boolean; } async function handleEnable(args: Args): Promise { const manager = McpServerEnablementManager.getInstance(); const name = normalizeServerId(args.name); // Check settings blocks const settings = loadSettings(); // Get all servers including extensions const servers = await getMcpServersFromConfig(); const normalizedServerNames = Object.keys(servers).map(normalizeServerId); if (!normalizedServerNames.includes(name)) { debugLogger.log( `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, ); return; } const result = await canLoadServer(name, { adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, allowedList: settings.merged.mcp?.allowed, excludedList: settings.merged.mcp?.excluded, }); if ( !result.allowed && (result.blockType === 'allowlist' || result.blockType === 'excludelist') ) { debugLogger.log(`${RED}Error:${RESET} ${result.reason}`); return; } if (args.session) { manager.clearSessionDisable(name); debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`); } else { await manager.enable(name); debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`); } if (result.blockType === 'admin') { debugLogger.log( `${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`, ); } } async function handleDisable(args: Args): Promise { const manager = McpServerEnablementManager.getInstance(); const name = normalizeServerId(args.name); // Get all servers including extensions const servers = await getMcpServersFromConfig(); const normalizedServerNames = Object.keys(servers).map(normalizeServerId); if (!normalizedServerNames.includes(name)) { debugLogger.log( `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, ); return; } if (args.session) { manager.disableForSession(name); debugLogger.log( `${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`, ); } else { await manager.disable(name); debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`); } } export const enableCommand: CommandModule = { command: 'enable ', describe: 'Enable an MCP server', builder: (yargs) => yargs .positional('name', { describe: 'MCP server name to enable', type: 'string', demandOption: true, }) .option('session', { describe: 'Clear session-only disable', type: 'boolean', default: false, }), handler: async (argv) => { await handleEnable(argv as Args); await exitCli(); }, }; export const disableCommand: CommandModule = { command: 'disable ', describe: 'Disable an MCP server', builder: (yargs) => yargs .positional('name', { describe: 'MCP server name to disable', type: 'string', demandOption: true, }) .option('session', { describe: 'Disable for current session only', type: 'boolean', default: false, }), handler: async (argv) => { await handleDisable(argv as Args); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/mcp/list.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings, mergeSettings, type LoadedSettings, } from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { McpServerEnablementManager } from '../../config/mcp/index.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn(), }; }); vi.mock('../../config/extensions/storage.js', () => ({ ExtensionStorage: { getUserExtensionsDir: vi.fn(), }, })); vi.mock('../../config/extension-manager.js'); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, createTransport: vi.fn(), MCPServerStatus: { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', BLOCKED: 'BLOCKED', DISABLED: 'DISABLED', }, Storage: Object.assign( vi.fn().mockImplementation((_cwd: string) => ({ getGlobalSettingsPath: () => '/tmp/gemini/settings.json', getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', })), { getGlobalSettingsPath: () => '/tmp/gemini/settings.json', getGlobalGeminiDir: () => '/tmp/gemini', }, ), GEMINI_DIR: '.gemini', getErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), }; }); vi.mock('@modelcontextprotocol/sdk/client/index.js'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); const mockedGetUserExtensionsDir = ExtensionStorage.getUserExtensionsDir as Mock; const mockedLoadSettings = loadSettings as Mock; const mockedCreateTransport = createTransport as Mock; const MockedClient = Client as Mock; const MockedExtensionManager = ExtensionManager as Mock; interface MockClient { connect: Mock; ping: Mock; close: Mock; } interface MockExtensionManager { loadExtensions: Mock; } interface MockTransport { close: Mock; } describe('mcp list command', () => { let mockClient: MockClient; let mockExtensionManager: MockExtensionManager; let mockTransport: MockTransport; beforeEach(() => { vi.resetAllMocks(); vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); McpServerEnablementManager.resetInstance(); // Use a mock for isFileEnabled to avoid reading real files vi.spyOn( McpServerEnablementManager.prototype, 'isFileEnabled', ).mockResolvedValue(true); mockTransport = { close: vi.fn() }; mockClient = { connect: vi.fn(), ping: vi.fn(), close: vi.fn(), }; mockExtensionManager = { loadExtensions: vi.fn(), }; MockedClient.mockImplementation(() => mockClient); MockedExtensionManager.mockImplementation(() => mockExtensionManager); mockedCreateTransport.mockResolvedValue(mockTransport); mockExtensionManager.loadExtensions.mockReturnValue([]); mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); }); afterEach(() => { vi.restoreAllMocks(); }); it('should display message when no servers configured', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: {} }, }); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.'); }); it('should display different server types with connected status', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, 'sse-server': { url: 'https://example.com/sse', type: 'sse' }, 'http-server': { httpUrl: 'https://example.com/http' }, 'http-server-by-default': { url: 'https://example.com/http' }, 'http-server-with-type': { url: 'https://example.com/http', type: 'http', }, }, }, isTrusted: true, }); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith('Configured MCP servers:\n'); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'stdio-server: /path/to/server arg1 (stdio) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'sse-server: https://example.com/sse (sse) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server: https://example.com/http (http) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server-by-default: https://example.com/http (http) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server-with-type: https://example.com/http (http) - Connected', ), ); }); it('should display disconnected status when connection fails', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, }, }); mockClient.connect.mockRejectedValue(new Error('Connection failed')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), ); }); it('should merge extension servers with config servers', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'config-server': { command: '/config/server' }, }, }, isTrusted: true, }); mockExtensionManager.loadExtensions.mockReturnValue([ { name: 'test-extension', mcpServers: { 'extension-server': { command: '/ext/server' } }, }, ]); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'config-server: /config/server (stdio) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'extension-server (from test-extension): /ext/server (stdio) - Connected', ), ); }); it('should filter servers based on admin allowlist passed in settings', async () => { const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true); settingsWithAllowlist.admin = { secureModeEnabled: false, extensions: { enabled: true }, skills: { enabled: true }, mcp: { enabled: true, config: { 'allowed-server': { url: 'http://allowed' }, }, requiredConfig: {}, }, }; settingsWithAllowlist.mcpServers = { 'allowed-server': { command: 'cmd1' }, 'forbidden-server': { command: 'cmd2' }, }; mockedLoadSettings.mockReturnValue({ merged: settingsWithAllowlist, }); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers({ merged: settingsWithAllowlist, isTrusted: true, } as unknown as LoadedSettings); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('allowed-server'), ); expect(debugLogger.log).not.toHaveBeenCalledWith( expect.stringContaining('forbidden-server'), ); expect(mockedCreateTransport).toHaveBeenCalledWith( 'allowed-server', expect.objectContaining({ url: 'http://allowed' }), // Should use admin config false, expect.anything(), ); }); it('should show stdio servers as disconnected in untrusted folders', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, }, isTrusted: false, }); // createTransport will throw in core if not trusted mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), ); }); it('should display blocked status for servers in excluded list', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcp: { excluded: ['blocked-server'], }, mcpServers: { 'blocked-server': { command: '/test/server' }, }, }, isTrusted: true, }); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'blocked-server: /test/server (stdio) - Blocked', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should display disabled status for servers disabled via enablement manager', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'disabled-server': { command: '/test/server' }, }, }, isTrusted: true, }); vi.spyOn( McpServerEnablementManager.prototype, 'isFileEnabled', ).mockResolvedValue(false); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'disabled-server: /test/server (stdio) - Disabled', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/commands/mcp/list.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; import { type MergedSettings, loadSettings, type LoadedSettings, } from '../../config/settings.js'; import { MCPServerStatus, createTransport, debugLogger, applyAdminAllowlist, getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; import type { MCPServerConfig } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { canLoadServer, McpServerEnablementManager, } from '../../config/mcp/index.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; import chalk from 'chalk'; export async function getMcpServersFromConfig( settings?: MergedSettings, ): Promise<{ mcpServers: Record; blockedServerNames: string[]; }> { if (!settings) { settings = loadSettings().merged; } const extensionManager = new ExtensionManager({ settings, workspaceDir: process.cwd(), requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); const mcpServers = { ...settings.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { return; } mcpServers[key] = { ...server, extension, }; }); } const adminAllowlist = settings.admin?.mcp?.config; const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist); return filteredResult; } async function testMCPConnection( serverName: string, config: MCPServerConfig, isTrusted: boolean, activeSettings: MergedSettings, ): Promise { // SECURITY: Only test connection if workspace is trusted or if it's a remote server. // stdio servers execute local commands and must never run in untrusted workspaces. const isStdio = !!config.command; if (isStdio && !isTrusted) { return MCPServerStatus.DISCONNECTED; } const client = new Client({ name: 'mcp-test-client', version: '0.0.1', }); const mcpContext = { sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars, }, emitMcpDiagnostic: ( severity: 'info' | 'warning' | 'error', message: string, error?: unknown, serverName?: string, ) => { // In non-interactive list, we log everything through debugLogger for consistency if (severity === 'error') { debugLogger.error( chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`), error, ); } else if (severity === 'warning') { debugLogger.warn( chalk.yellow( `Warning${serverName ? ` (${serverName})` : ''}: ${message}`, ), error, ); } else { debugLogger.log(message, error); } }, isTrustedFolder: () => isTrusted, }; let transport; try { // Use the same transport creation logic as core transport = await createTransport(serverName, config, false, mcpContext); } catch (_error) { await client.close(); return MCPServerStatus.DISCONNECTED; } try { // Attempt actual MCP connection with short timeout await client.connect(transport, { timeout: 5000 }); // 5s timeout // Test basic MCP protocol by pinging the server await client.ping(); await client.close(); return MCPServerStatus.CONNECTED; } catch (_error) { await transport.close(); return MCPServerStatus.DISCONNECTED; } } async function getServerStatus( serverName: string, server: MCPServerConfig, isTrusted: boolean, activeSettings: MergedSettings, ): Promise { const mcpEnablementManager = McpServerEnablementManager.getInstance(); const loadResult = await canLoadServer(serverName, { adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true, allowedList: activeSettings.mcp?.allowed, excludedList: activeSettings.mcp?.excluded, enablement: mcpEnablementManager.getEnablementCallbacks(), }); if (!loadResult.allowed) { if ( loadResult.blockType === 'admin' || loadResult.blockType === 'allowlist' || loadResult.blockType === 'excludelist' ) { return MCPServerStatus.BLOCKED; } return MCPServerStatus.DISABLED; } // Test all server types by attempting actual connection return testMCPConnection(serverName, server, isTrusted, activeSettings); } export async function listMcpServers( loadedSettingsArg?: LoadedSettings, ): Promise { const loadedSettings = loadedSettingsArg ?? loadSettings(); const activeSettings = loadedSettings.merged; const { mcpServers, blockedServerNames } = await getMcpServersFromConfig(activeSettings); const serverNames = Object.keys(mcpServers); if (blockedServerNames.length > 0) { const message = getAdminBlockedMcpServersMessage( blockedServerNames, undefined, ); debugLogger.log(chalk.yellow(message + '\n')); } if (serverNames.length === 0) { if (blockedServerNames.length === 0) { debugLogger.log('No MCP servers configured.'); } return; } debugLogger.log('Configured MCP servers:\n'); for (const serverName of serverNames) { const server = mcpServers[serverName]; const status = await getServerStatus( serverName, server, loadedSettings.isTrusted, activeSettings, ); let statusIndicator = ''; let statusText = ''; switch (status) { case MCPServerStatus.CONNECTED: statusIndicator = chalk.green('✓'); statusText = 'Connected'; break; case MCPServerStatus.CONNECTING: statusIndicator = chalk.yellow('…'); statusText = 'Connecting'; break; case MCPServerStatus.BLOCKED: statusIndicator = chalk.red('⛔'); statusText = 'Blocked'; break; case MCPServerStatus.DISABLED: statusIndicator = chalk.gray('○'); statusText = 'Disabled'; break; case MCPServerStatus.DISCONNECTED: default: statusIndicator = chalk.red('✗'); statusText = 'Disconnected'; break; } let serverInfo = serverName + (server.extension?.name ? ` (from ${server.extension.name})` : '') + ': '; if (server.httpUrl) { serverInfo += `${server.httpUrl} (http)`; } else if (server.url) { const type = server.type || 'http'; serverInfo += `${server.url} (${type})`; } else if (server.command) { serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; } debugLogger.log(`${statusIndicator} ${serverInfo} - ${statusText}`); } } interface ListArgs { loadedSettings?: LoadedSettings; } export const listCommand: CommandModule = { command: 'list', describe: 'List all configured MCP servers', handler: async (argv) => { await listMcpServers(argv.loadedSettings); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/mcp/remove.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import yargs, { type Argv } from 'yargs'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; import { removeCommand } from './remove.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core'; vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { ...actualFs, existsSync: vi.fn(actualFs.existsSync), readFileSync: vi.fn(actualFs.readFileSync), writeFileSync: vi.fn(actualFs.writeFileSync), mkdirSync: vi.fn(actualFs.mkdirSync), }; }); vi.mock('fs/promises', () => ({ readFile: vi.fn(), writeFile: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); vi.mock('../../config/trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: undefined, })), isFolderTrustEnabled: vi.fn(() => false), })); describe('mcp remove command', () => { describe('unit tests with mocks', () => { let parser: Argv; let mockSetValue: Mock; let mockSettings: Record; beforeEach(async () => { vi.resetAllMocks(); mockSetValue = vi.fn(); mockSettings = { mcpServers: { 'test-server': { command: 'echo "hello"', }, }, }; vi.spyOn( await import('../../config/settings.js'), 'loadSettings', ).mockReturnValue({ forScope: () => ({ settings: mockSettings }), setValue: mockSetValue, workspace: { path: '/path/to/project' }, user: { path: '/home/user' }, } as unknown as LoadedSettings); const yargsInstance = yargs([]).command(removeCommand); parser = yargsInstance; }); it('should remove a server from project settings', async () => { await parser.parseAsync('remove test-server'); expect(mockSetValue).toHaveBeenCalledWith( SettingScope.Workspace, 'mcpServers', {}, ); }); it('should show a message if server not found', async () => { const debugLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); await parser.parseAsync('remove non-existent-server'); expect(mockSetValue).not.toHaveBeenCalled(); expect(debugLogSpy).toHaveBeenCalledWith( 'Server "non-existent-server" not found in project settings.', ); debugLogSpy.mockRestore(); }); }); describe('integration tests with real file I/O', () => { let tempDir: string; let settingsDir: string; let settingsPath: string; let parser: Argv; let cwdSpy: ReturnType; beforeEach(() => { vi.resetAllMocks(); vi.restoreAllMocks(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-remove-test-')); settingsDir = path.join(tempDir, GEMINI_DIR); settingsPath = path.join(settingsDir, 'settings.json'); fs.mkdirSync(settingsDir, { recursive: true }); cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir); parser = yargs([]).command(removeCommand); }); afterEach(() => { cwdSpy.mockRestore(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); it('should actually remove a server from the settings file', async () => { const originalContent = `{ "mcpServers": { "server-to-keep": { "command": "node", "args": ["keep.js"] }, "server-to-remove": { "command": "node", "args": ["remove.js"] } } }`; fs.writeFileSync(settingsPath, originalContent, 'utf-8'); const debugLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); await parser.parseAsync('remove server-to-remove'); const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); expect(updatedContent).toContain('"server-to-keep"'); expect(updatedContent).not.toContain('"server-to-remove"'); expect(debugLogSpy).toHaveBeenCalledWith( 'Server "server-to-remove" removed from project settings.', ); debugLogSpy.mockRestore(); }); it('should preserve comments when removing a server', async () => { const originalContent = `{ "mcpServers": { // Server to keep "context7": { "command": "node", "args": ["server.js"] }, // Server to remove "oldServer": { "command": "old", "args": ["old.js"] } } }`; fs.writeFileSync(settingsPath, originalContent, 'utf-8'); const debugLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); await parser.parseAsync('remove oldServer'); const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); expect(updatedContent).toContain('// Server to keep'); expect(updatedContent).toContain('"context7"'); expect(updatedContent).not.toContain('"oldServer"'); expect(updatedContent).toContain('// Server to remove'); debugLogSpy.mockRestore(); }); it('should handle removing the only server', async () => { const originalContent = `{ "mcpServers": { "only-server": { "command": "node", "args": ["server.js"] } } }`; fs.writeFileSync(settingsPath, originalContent, 'utf-8'); const debugLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); await parser.parseAsync('remove only-server'); const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); expect(updatedContent).toContain('"mcpServers"'); expect(updatedContent).not.toContain('"only-server"'); expect(updatedContent).toMatch(/"mcpServers"\s*:\s*\{\s*\}/); debugLogSpy.mockRestore(); }); it('should preserve other settings when removing a server', async () => { // Create settings file with other settings // Note: "model" will be migrated to "model": { "name": ... } format const originalContent = `{ "model": { "name": "gemini-2.5-pro" }, "mcpServers": { "server1": { "command": "node", "args": ["s1.js"] }, "server2": { "command": "node", "args": ["s2.js"] } }, "ui": { "theme": "dark" } }`; fs.writeFileSync(settingsPath, originalContent, 'utf-8'); const debugLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); await parser.parseAsync('remove server1'); const updatedContent = fs.readFileSync(settingsPath, 'utf-8'); expect(updatedContent).toContain('"model"'); expect(updatedContent).toContain('"gemini-2.5-pro"'); expect(updatedContent).toContain('"server2"'); expect(updatedContent).toContain('"ui"'); expect(updatedContent).toContain('"theme": "dark"'); expect(updatedContent).not.toContain('"server1"'); debugLogSpy.mockRestore(); }); }); }); ================================================ FILE: packages/cli/src/commands/mcp/remove.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // File for 'gemini mcp remove' command import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; async function removeMcpServer( name: string, options: { scope: string; }, ) { const { scope } = options; const settingsScope = scope === 'user' ? SettingScope.User : SettingScope.Workspace; const settings = loadSettings(); const existingSettings = settings.forScope(settingsScope).settings; const mcpServers = existingSettings.mcpServers || {}; if (!mcpServers[name]) { debugLogger.log(`Server "${name}" not found in ${scope} settings.`); return; } delete mcpServers[name]; settings.setValue(settingsScope, 'mcpServers', mcpServers); debugLogger.log(`Server "${name}" removed from ${scope} settings.`); } export const removeCommand: CommandModule = { command: 'remove ', describe: 'Remove a server', builder: (yargs) => yargs .usage('Usage: gemini mcp remove [options] ') .positional('name', { describe: 'Name of the server', type: 'string', demandOption: true, }) .option('scope', { alias: 's', describe: 'Configuration scope (user or project)', type: 'string', default: 'project', choices: ['user', 'project'], }), handler: async (argv) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion await removeMcpServer(argv['name'] as string, { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as string, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/mcp.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { mcpCommand } from './mcp.js'; import yargs, { type Argv } from 'yargs'; describe('mcp command', () => { it('should have correct command definition', () => { expect(mcpCommand.command).toBe('mcp'); expect(mcpCommand.describe).toBe('Manage MCP servers'); expect(typeof mcpCommand.builder).toBe('function'); expect(typeof mcpCommand.handler).toBe('function'); }); it('should show help when no subcommand is provided', async () => { const yargsInstance = yargs(); (mcpCommand.builder as (y: Argv) => Argv)(yargsInstance); const parser = yargsInstance.command(mcpCommand).help(); // Mock console.log and console.error to catch help output const consoleLogMock = vi .spyOn(console, 'log') .mockImplementation(() => {}); const consoleErrorMock = vi .spyOn(console, 'error') .mockImplementation(() => {}); try { await parser.parse('mcp'); } catch (_error) { // yargs might throw an error when demandCommand is not met } // Check if help output is shown const helpOutput = consoleLogMock.mock.calls.join('\n') + consoleErrorMock.mock.calls.join('\n'); expect(helpOutput).toContain('Manage MCP servers'); expect(helpOutput).toContain('Commands:'); expect(helpOutput).toContain('add'); expect(helpOutput).toContain('remove'); expect(helpOutput).toContain('list'); consoleLogMock.mockRestore(); consoleErrorMock.mockRestore(); }); it('should register add, remove, and list subcommands', () => { const mockYargs = { command: vi.fn().mockReturnThis(), demandCommand: vi.fn().mockReturnThis(), version: vi.fn().mockReturnThis(), middleware: vi.fn().mockReturnThis(), }; (mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv); expect(mockYargs.command).toHaveBeenCalledTimes(5); // Verify that the specific subcommands are registered const commandCalls = mockYargs.command.mock.calls; const commandNames = commandCalls.map((call) => call[0].command); expect(commandNames).toContain('add [args...]'); expect(commandNames).toContain('remove '); expect(commandNames).toContain('list'); expect(commandNames).toContain('enable '); expect(commandNames).toContain('disable '); expect(mockYargs.demandCommand).toHaveBeenCalledWith( 1, 'You need at least one command before continuing.', ); }); }); ================================================ FILE: packages/cli/src/commands/mcp.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // File for 'gemini mcp' command import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; import { enableCommand, disableCommand } from './mcp/enableDisable.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; export const mcpCommand: CommandModule = { command: 'mcp', describe: 'Manage MCP servers', builder: (yargs: Argv) => yargs .middleware((argv) => { initializeOutputListenersAndFlush(); argv['isCommand'] = true; }) .command(defer(addCommand, 'mcp')) .command(defer(removeCommand, 'mcp')) .command(defer(listCommand, 'mcp')) .command(defer(enableCommand, 'mcp')) .command(defer(disableCommand, 'mcp')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { // yargs will automatically show help if no subcommand is provided // thanks to demandCommand(1) in the builder. }, }; ================================================ FILE: packages/cli/src/commands/skills/disable.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { handleDisable, disableCommand } from './disable.js'; import { loadSettings, SettingScope, type LoadedSettings, type LoadableSettingScope, } from '../../config/settings.js'; const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { const { createMockDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return createMockDebugLogger({ stripAnsi: true }); }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger, }; }); vi.mock('../../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn(), isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), }; }); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('skills disable command', () => { const mockLoadSettings = vi.mocked(loadSettings); beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleDisable', () => { it('should disable an enabled skill in user scope', async () => { const mockSettings = { forScope: vi.fn().mockReturnValue({ settings: { skills: { disabled: [] } }, path: '/user/settings.json', }), setValue: vi.fn(), }; mockLoadSettings.mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleDisable({ name: 'skill1', scope: SettingScope.User as LoadableSettingScope, }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'skills.disabled', ['skill1'], ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" disabled by adding it to the disabled list in user (/user/settings.json) settings.', ); }); it('should disable an enabled skill in workspace scope', async () => { const mockSettings = { forScope: vi.fn().mockReturnValue({ settings: { skills: { disabled: [] } }, path: '/workspace/.gemini/settings.json', }), setValue: vi.fn(), }; mockLoadSettings.mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleDisable({ name: 'skill1', scope: SettingScope.Workspace as LoadableSettingScope, }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'skills.disabled', ['skill1'], ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace/.gemini/settings.json) settings.', ); }); it('should log a message if the skill is already disabled', async () => { const mockSettings = { forScope: vi.fn().mockReturnValue({ settings: { skills: { disabled: ['skill1'] } }, path: '/user/settings.json', }), setValue: vi.fn(), }; vi.mocked(loadSettings).mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleDisable({ name: 'skill1', scope: SettingScope.User }); expect(mockSettings.setValue).not.toHaveBeenCalled(); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" is already disabled.', ); }); }); describe('disableCommand', () => { it('should have correct command and describe', () => { expect(disableCommand.command).toBe('disable [--scope]'); expect(disableCommand.describe).toBe('Disables an agent skill.'); }); }); }); ================================================ FILE: packages/cli/src/commands/skills/disable.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { disableSkill } from '../../utils/skillSettings.js'; import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; import chalk from 'chalk'; interface DisableArgs { name: string; scope: SettingScope; } export async function handleDisable(args: DisableArgs) { const { name, scope } = args; const workspaceDir = process.cwd(); const settings = loadSettings(workspaceDir); const result = disableSkill(settings, name, scope); const feedback = renderSkillActionFeedback( result, (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, ); debugLogger.log(feedback); } export const disableCommand: CommandModule = { command: 'disable [--scope]', describe: 'Disables an agent skill.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the skill to disable.', type: 'string', demandOption: true, }) .option('scope', { alias: 's', describe: 'The scope to disable the skill in (user or workspace).', type: 'string', default: 'workspace', choices: ['user', 'workspace'], }), handler: async (argv) => { const scope = argv['scope'] === 'workspace' ? SettingScope.Workspace : SettingScope.User; await handleDisable({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, scope, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills/enable.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { handleEnable, enableCommand } from './enable.js'; import { loadSettings, SettingScope, type LoadedSettings, } from '../../config/settings.js'; const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { const { createMockDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return createMockDebugLogger({ stripAnsi: true }); }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger, }; }); vi.mock('../../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn(), isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), }; }); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('skills enable command', () => { const mockLoadSettings = vi.mocked(loadSettings); beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleEnable', () => { it('should enable a disabled skill in user scope', async () => { const mockSettings = { forScope: vi.fn().mockImplementation((scope) => { if (scope === SettingScope.User) { return { settings: { skills: { disabled: ['skill1'] } }, path: '/user/settings.json', }; } return { settings: {}, path: '/workspace/settings.json' }; }), setValue: vi.fn(), }; mockLoadSettings.mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleEnable({ name: 'skill1' }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'skills.disabled', [], ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" enabled by removing it from the disabled list in user (/user/settings.json) and workspace (/workspace/settings.json) settings.', ); }); it('should enable a skill across multiple scopes', async () => { const mockSettings = { forScope: vi.fn().mockImplementation((scope) => { if (scope === SettingScope.User) { return { settings: { skills: { disabled: ['skill1'] } }, path: '/user/settings.json', }; } if (scope === SettingScope.Workspace) { return { settings: { skills: { disabled: ['skill1'] } }, path: '/workspace/settings.json', }; } return { settings: {}, path: '' }; }), setValue: vi.fn(), }; mockLoadSettings.mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleEnable({ name: 'skill1' }); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'skills.disabled', [], ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'skills.disabled', [], ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace/settings.json) and user (/user/settings.json) settings.', ); }); it('should log a message if the skill is already enabled', async () => { const mockSettings = { forScope: vi.fn().mockReturnValue({ settings: { skills: { disabled: [] } }, path: '/user/settings.json', }), setValue: vi.fn(), }; mockLoadSettings.mockReturnValue( mockSettings as unknown as LoadedSettings, ); await handleEnable({ name: 'skill1' }); expect(mockSettings.setValue).not.toHaveBeenCalled(); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Skill "skill1" is already enabled.', ); }); }); describe('enableCommand', () => { it('should have correct command and describe', () => { expect(enableCommand.command).toBe('enable '); expect(enableCommand.describe).toBe('Enables an agent skill.'); }); }); }); ================================================ FILE: packages/cli/src/commands/skills/enable.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { loadSettings } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { enableSkill } from '../../utils/skillSettings.js'; import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; import chalk from 'chalk'; interface EnableArgs { name: string; } export async function handleEnable(args: EnableArgs) { const { name } = args; const workspaceDir = process.cwd(); const settings = loadSettings(workspaceDir); const result = enableSkill(settings, name); const feedback = renderSkillActionFeedback( result, (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, ); debugLogger.log(feedback); } export const enableCommand: CommandModule = { command: 'enable ', describe: 'Enables an agent skill.', builder: (yargs) => yargs.positional('name', { describe: 'The name of the skill to enable.', type: 'string', demandOption: true, }), handler: async (argv) => { await handleEnable({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills/install.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach } from 'vitest'; const mockInstallSkill = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockSkillsConsentString = vi.hoisted(() => vi.fn()); vi.mock('../../utils/skillUtils.js', () => ({ installSkill: mockInstallSkill, })); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, skillsConsentString: mockSkillsConsentString, })); const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { const { createMockDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return createMockDebugLogger({ stripAnsi: true }); }); vi.mock('@google/gemini-cli-core', () => ({ debugLogger, getErrorMessage: vi.fn((e: unknown) => e instanceof Error ? e.message : String(e), ), })); import { handleInstall, installCommand } from './install.js'; describe('skill install command', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); mockSkillsConsentString.mockResolvedValue('Mock Consent String'); mockRequestConsentNonInteractive.mockResolvedValue(true); }); describe('installCommand', () => { it('should have correct command and describe', () => { expect(installCommand.command).toBe( 'install [--scope] [--path]', ); expect(installCommand.describe).toBe( 'Installs an agent skill from a git repository URL or a local path.', ); }); }); it('should call installSkill with correct arguments for user scope', async () => { mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { await rc([]); return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; }); await handleInstall({ source: 'https://example.com/repo.git', scope: 'user', }); expect(mockInstallSkill).toHaveBeenCalledWith( 'https://example.com/repo.git', 'user', undefined, expect.any(Function), expect.any(Function), ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('Successfully installed skill: test-skill'), ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('location: /mock/user/skills/test-skill'), ); expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith( 'Mock Consent String', ); }); it('should skip prompt and log consent when --consent is provided', async () => { mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { await rc([]); return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; }); await handleInstall({ source: 'https://example.com/repo.git', consent: true, }); expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled(); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'You have consented to the following:', ); expect(emitConsoleLog).toHaveBeenCalledWith('log', 'Mock Consent String'); expect(mockInstallSkill).toHaveBeenCalled(); }); it('should abort installation if consent is denied', async () => { mockRequestConsentNonInteractive.mockResolvedValue(false); mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { if (!(await rc([]))) { throw new Error('Skill installation cancelled by user.'); } return []; }); await handleInstall({ source: 'https://example.com/repo.git', }); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Skill installation cancelled by user.', ); expect(process.exit).toHaveBeenCalledWith(1); }); it('should call installSkill with correct arguments for workspace scope and subpath', async () => { mockInstallSkill.mockResolvedValue([ { name: 'test-skill', location: '/mock/workspace/skills/test-skill' }, ]); await handleInstall({ source: 'https://example.com/repo.git', scope: 'workspace', path: 'my-skills-dir', }); expect(mockInstallSkill).toHaveBeenCalledWith( 'https://example.com/repo.git', 'workspace', 'my-skills-dir', expect.any(Function), expect.any(Function), ); }); it('should handle errors gracefully', async () => { mockInstallSkill.mockRejectedValue(new Error('Install failed')); await handleInstall({ source: '/local/path' }); expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Install failed'); expect(process.exit).toHaveBeenCalledWith(1); }); }); ================================================ FILE: packages/cli/src/commands/skills/install.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, type SkillDefinition, getErrorMessage, } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { installSkill } from '../../utils/skillUtils.js'; import chalk from 'chalk'; import { requestConsentNonInteractive, skillsConsentString, } from '../../config/extensions/consent.js'; interface InstallArgs { source: string; scope?: 'user' | 'workspace'; path?: string; consent?: boolean; } export async function handleInstall(args: InstallArgs) { try { const { source, consent } = args; const scope = args.scope ?? 'user'; const subpath = args.path; const requestConsent = async ( skills: SkillDefinition[], targetDir: string, ) => { if (consent) { debugLogger.log('You have consented to the following:'); debugLogger.log(await skillsConsentString(skills, source, targetDir)); return true; } return requestConsentNonInteractive( await skillsConsentString(skills, source, targetDir), ); }; const installedSkills = await installSkill( source, scope, subpath, (msg) => { debugLogger.log(msg); }, requestConsent, ); for (const skill of installedSkills) { debugLogger.log( chalk.green( `Successfully installed skill: ${chalk.bold(skill.name)} (scope: ${scope}, location: ${skill.location})`, ), ); } } catch (error) { debugLogger.error(getErrorMessage(error)); await exitCli(1); } } export const installCommand: CommandModule = { command: 'install [--scope] [--path]', describe: 'Installs an agent skill from a git repository URL or a local path.', builder: (yargs) => yargs .positional('source', { describe: 'The git repository URL or local path of the skill to install.', type: 'string', demandOption: true, }) .option('scope', { describe: 'The scope to install the skill into. Defaults to "user" (global).', choices: ['user', 'workspace'], default: 'user', }) .option('path', { describe: 'Sub-path within the repository to install from (only used for git repository sources).', type: 'string', }) .option('consent', { describe: 'Acknowledge the security risks of installing a skill and skip the confirmation prompt.', type: 'boolean', default: false, }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); } return true; }), handler: async (argv) => { await handleInstall({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion source: argv['source'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills/link.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { handleLink, linkCommand } from './link.js'; const mockLinkSkill = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockSkillsConsentString = vi.hoisted(() => vi.fn()); vi.mock('../../utils/skillUtils.js', () => ({ linkSkill: mockLinkSkill, })); const { debugLogger } = await vi.hoisted(async () => { const { createMockDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return createMockDebugLogger({ stripAnsi: false }); }); vi.mock('@google/gemini-cli-core', () => ({ debugLogger, getErrorMessage: vi.fn((e: unknown) => e instanceof Error ? e.message : String(e), ), })); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, skillsConsentString: mockSkillsConsentString, })); describe('skills link command', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); }); describe('linkCommand', () => { it('should have correct command and describe', () => { expect(linkCommand.command).toBe('link '); expect(linkCommand.describe).toContain('Links an agent skill'); }); }); it('should call linkSkill with correct arguments', async () => { const sourcePath = '/source/path'; mockLinkSkill.mockResolvedValue([ { name: 'test-skill', location: '/dest/path' }, ]); await handleLink({ path: sourcePath, scope: 'user' }); expect(mockLinkSkill).toHaveBeenCalledWith( sourcePath, 'user', expect.any(Function), expect.any(Function), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('Successfully linked skills'), ); }); it('should handle linkSkill failure', async () => { mockLinkSkill.mockRejectedValue(new Error('Link failed')); await handleLink({ path: '/some/path' }); expect(debugLogger.error).toHaveBeenCalledWith('Link failed'); expect(process.exit).toHaveBeenCalledWith(1); }); }); ================================================ FILE: packages/cli/src/commands/skills/link.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import chalk from 'chalk'; import { exitCli } from '../utils.js'; import { requestConsentNonInteractive, skillsConsentString, } from '../../config/extensions/consent.js'; import { linkSkill } from '../../utils/skillUtils.js'; interface LinkArgs { path: string; scope?: 'user' | 'workspace'; consent?: boolean; } export async function handleLink(args: LinkArgs) { try { const { scope = 'user', consent } = args; await linkSkill( args.path, scope, (msg) => debugLogger.log(msg), async (skills, targetDir) => { const consentString = await skillsConsentString( skills, args.path, targetDir, true, ); if (consent) { debugLogger.log('You have consented to the following:'); debugLogger.log(consentString); return true; } return requestConsentNonInteractive(consentString); }, ); debugLogger.log(chalk.green('\nSuccessfully linked skills.')); } catch (error) { debugLogger.error(getErrorMessage(error)); await exitCli(1); } } export const linkCommand: CommandModule = { command: 'link ', describe: 'Links an agent skill from a local path. Updates to the source will be reflected immediately.', builder: (yargs) => yargs .positional('path', { describe: 'The local path of the skill to link.', type: 'string', demandOption: true, }) .option('scope', { describe: 'The scope to link the skill into. Defaults to "user" (global).', choices: ['user', 'workspace'], default: 'user', }) .option('consent', { describe: 'Acknowledge the security risks of linking a skill and skip the confirmation prompt.', type: 'boolean', default: false, }) .check((argv) => { if (!argv.path) { throw new Error('The path argument must be provided.'); } return true; }), handler: async (argv) => { await handleLink({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion path: argv['path'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills/list.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { coreEvents, type Config } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { loadCliConfig } from '../../config/config.js'; import chalk from 'chalk'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const { mockCoreDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return mockCoreDebugLogger( await importOriginal(), { stripAnsi: false, }, ); }); vi.mock('../../config/settings.js'); vi.mock('../../config/config.js'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('skills list command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockLoadCliConfig = vi.mocked(loadCliConfig); beforeEach(async () => { vi.clearAllMocks(); mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); }); afterEach(() => { vi.restoreAllMocks(); }); describe('handleList', () => { it('should log a message if no skills are discovered', async () => { const mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), }), }; mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); await handleList({}); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'No skills discovered.', ); }); it('should list all discovered skills', async () => { const skills = [ { name: 'skill1', description: 'desc1', disabled: false, location: '/path/to/skill1', }, { name: 'skill2', description: 'desc2', disabled: true, location: '/path/to/skill2', }, ]; const mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), }), }; mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); await handleList({}); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', chalk.bold('Discovered Agent Skills:'), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('skill1'), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.green('[Enabled]')), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('skill2'), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.red('[Disabled]')), ); }); it('should filter built-in skills by default and show them with { all: true }', async () => { const skills = [ { name: 'regular', description: 'desc1', disabled: false, location: '/loc1', }, { name: 'builtin', description: 'desc2', disabled: false, location: '/loc2', isBuiltin: true, }, ]; const mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), }), }; mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); // Default await handleList({ all: false }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('regular'), ); expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith( 'log', expect.stringContaining('builtin'), ); vi.clearAllMocks(); // With all: true await handleList({ all: true }); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('regular'), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('builtin'), ); expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.gray(' [Built-in]')), ); }); it('should throw an error when listing fails', async () => { mockLoadCliConfig.mockRejectedValue(new Error('List failed')); await expect(handleList({})).rejects.toThrow('List failed'); }); }); describe('listCommand', () => { const command = listCommand; it('should have correct command and describe', () => { expect(command.command).toBe('list [--all]'); expect(command.describe).toBe('Lists discovered agent skills.'); }); }); }); ================================================ FILE: packages/cli/src/commands/skills/list.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger } from '@google/gemini-cli-core'; import { loadSettings } from '../../config/settings.js'; import { loadCliConfig, type CliArgs } from '../../config/config.js'; import { exitCli } from '../utils.js'; import chalk from 'chalk'; export async function handleList(args: { all?: boolean }) { const workspaceDir = process.cwd(); const settings = loadSettings(workspaceDir); const config = await loadCliConfig( settings.merged, 'skills-list-session', // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion { debug: false, } as Partial as CliArgs, { cwd: workspaceDir }, ); // Initialize to trigger extension loading and skill discovery await config.initialize(); const skillManager = config.getSkillManager(); const skills = args.all ? skillManager.getAllSkills() : skillManager.getAllSkills().filter((s) => !s.isBuiltin); // Sort skills: non-built-in first, then alphabetically by name skills.sort((a, b) => { if (a.isBuiltin === b.isBuiltin) { return a.name.localeCompare(b.name); } return a.isBuiltin ? 1 : -1; }); if (skills.length === 0) { debugLogger.log('No skills discovered.'); return; } debugLogger.log(chalk.bold('Discovered Agent Skills:')); debugLogger.log(''); for (const skill of skills) { const status = skill.disabled ? chalk.red('[Disabled]') : chalk.green('[Enabled]'); const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : ''; debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`); debugLogger.log(` Description: ${skill.description}`); debugLogger.log(` Location: ${skill.location}`); debugLogger.log(''); } } export const listCommand: CommandModule = { command: 'list [--all]', describe: 'Lists discovered agent skills.', builder: (yargs) => yargs.option('all', { type: 'boolean', description: 'Show all skills, including built-in ones.', default: false, }), handler: async (argv) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion await handleList({ all: argv['all'] as boolean }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills/uninstall.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockUninstallSkill = vi.hoisted(() => vi.fn()); vi.mock('../../utils/skillUtils.js', () => ({ uninstallSkill: mockUninstallSkill, })); const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { const { createMockDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); return createMockDebugLogger({ stripAnsi: true }); }); vi.mock('@google/gemini-cli-core', () => ({ debugLogger, getErrorMessage: vi.fn((e: unknown) => e instanceof Error ? e.message : String(e), ), })); import { handleUninstall, uninstallCommand } from './uninstall.js'; describe('skill uninstall command', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); }); describe('uninstallCommand', () => { it('should have correct command and describe', () => { expect(uninstallCommand.command).toBe('uninstall [--scope]'); expect(uninstallCommand.describe).toBe( 'Uninstalls an agent skill by name.', ); }); }); it('should call uninstallSkill with correct arguments for user scope', async () => { mockUninstallSkill.mockResolvedValue({ location: '/mock/user/skills/test-skill', }); await handleUninstall({ name: 'test-skill', scope: 'user', }); expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user'); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('Successfully uninstalled skill: test-skill'), ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('location: /mock/user/skills/test-skill'), ); }); it('should call uninstallSkill with correct arguments for workspace scope', async () => { mockUninstallSkill.mockResolvedValue({ location: '/mock/workspace/skills/test-skill', }); await handleUninstall({ name: 'test-skill', scope: 'workspace', }); expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'workspace'); }); it('should log an error if skill is not found', async () => { mockUninstallSkill.mockResolvedValue(null); await handleUninstall({ name: 'test-skill' }); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Skill "test-skill" is not installed in the user scope.', ); }); it('should handle errors gracefully', async () => { mockUninstallSkill.mockRejectedValue(new Error('Uninstall failed')); await handleUninstall({ name: 'test-skill' }); expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Uninstall failed'); expect(process.exit).toHaveBeenCalledWith(1); }); }); ================================================ FILE: packages/cli/src/commands/skills/uninstall.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { uninstallSkill } from '../../utils/skillUtils.js'; import chalk from 'chalk'; interface UninstallArgs { name: string; scope?: 'user' | 'workspace'; } export async function handleUninstall(args: UninstallArgs) { try { const { name } = args; const scope = args.scope ?? 'user'; const result = await uninstallSkill(name, scope); if (result) { debugLogger.log( chalk.green( `Successfully uninstalled skill: ${chalk.bold(name)} (scope: ${scope}, location: ${result.location})`, ), ); } else { debugLogger.error( `Skill "${name}" is not installed in the ${scope} scope.`, ); } } catch (error) { debugLogger.error(getErrorMessage(error)); await exitCli(1); } } export const uninstallCommand: CommandModule = { command: 'uninstall [--scope]', describe: 'Uninstalls an agent skill by name.', builder: (yargs) => yargs .positional('name', { describe: 'The name of the skill to uninstall.', type: 'string', demandOption: true, }) .option('scope', { describe: 'The scope to uninstall the skill from. Defaults to "user" (global).', choices: ['user', 'workspace'], default: 'user', }) .check((argv) => { if (!argv.name) { throw new Error('The skill name must be provided.'); } return true; }), handler: async (argv) => { await handleUninstall({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion name: argv['name'] as string, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion scope: argv['scope'] as 'user' | 'workspace', }); await exitCli(); }, }; ================================================ FILE: packages/cli/src/commands/skills.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { skillsCommand } from './skills.js'; vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } })); vi.mock('./skills/enable.js', () => ({ enableCommand: { command: 'enable ' }, })); vi.mock('./skills/disable.js', () => ({ disableCommand: { command: 'disable ' }, })); vi.mock('../gemini.js', () => ({ initializeOutputListenersAndFlush: vi.fn(), })); describe('skillsCommand', () => { it('should have correct command and aliases', () => { expect(skillsCommand.command).toBe('skills '); expect(skillsCommand.aliases).toEqual(['skill']); expect(skillsCommand.describe).toBe('Manage agent skills.'); }); it('should register all subcommands in builder', () => { const mockYargs = { middleware: vi.fn().mockReturnThis(), command: vi.fn().mockReturnThis(), demandCommand: vi.fn().mockReturnThis(), version: vi.fn().mockReturnThis(), }; // @ts-expect-error - Mocking yargs skillsCommand.builder(mockYargs); expect(mockYargs.middleware).toHaveBeenCalled(); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'list' }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'enable ', }), ); expect(mockYargs.command).toHaveBeenCalledWith( expect.objectContaining({ command: 'disable ', }), ); expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); expect(mockYargs.version).toHaveBeenCalledWith(false); }); it('should have a handler that does nothing', () => { // @ts-expect-error - Handler doesn't take arguments in this case expect(skillsCommand.handler()).toBeUndefined(); }); }); ================================================ FILE: packages/cli/src/commands/skills.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandModule } from 'yargs'; import { listCommand } from './skills/list.js'; import { enableCommand } from './skills/enable.js'; import { disableCommand } from './skills/disable.js'; import { installCommand } from './skills/install.js'; import { linkCommand } from './skills/link.js'; import { uninstallCommand } from './skills/uninstall.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; export const skillsCommand: CommandModule = { command: 'skills ', aliases: ['skill'], describe: 'Manage agent skills.', builder: (yargs) => yargs .middleware((argv) => { initializeOutputListenersAndFlush(); argv['isCommand'] = true; }) .command(defer(listCommand, 'skills')) .command(defer(enableCommand, 'skills')) .command(defer(disableCommand, 'skills')) .command(defer(installCommand, 'skills')) .command(defer(linkCommand, 'skills')) .command(defer(uninstallCommand, 'skills')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { // This handler is not called when a subcommand is provided. // Yargs will show the help menu. }, }; ================================================ FILE: packages/cli/src/commands/utils.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { exitCli } from './utils.js'; import { runExitCleanup } from '../utils/cleanup.js'; vi.mock('../utils/cleanup.js', () => ({ runExitCleanup: vi.fn(), })); describe('utils', () => { const originalProcessExit = process.exit; beforeEach(() => { // @ts-expect-error - Mocking process.exit process.exit = vi.fn(); }); afterEach(() => { process.exit = originalProcessExit; vi.clearAllMocks(); }); describe('exitCli', () => { it('should call runExitCleanup and process.exit with default exit code 0', async () => { await exitCli(); expect(runExitCleanup).toHaveBeenCalled(); expect(process.exit).toHaveBeenCalledWith(0); }); it('should call runExitCleanup and process.exit with specified exit code', async () => { await exitCli(1); expect(runExitCleanup).toHaveBeenCalled(); expect(process.exit).toHaveBeenCalledWith(1); }); }); }); ================================================ FILE: packages/cli/src/commands/utils.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { runExitCleanup } from '../utils/cleanup.js'; export async function exitCli(exitCode = 0) { await runExitCleanup(); process.exit(exitCode); } ================================================ FILE: packages/cli/src/config/auth.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { AuthType } from '@google/gemini-cli-core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateAuthMethod } from './auth.js'; vi.mock('./settings.js', () => ({ loadEnvironment: vi.fn(), loadSettings: vi.fn().mockReturnValue({ merged: vi.fn().mockReturnValue({}), }), })); describe('validateAuthMethod', () => { beforeEach(() => { vi.stubEnv('GEMINI_API_KEY', undefined); vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined); vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined); vi.stubEnv('GOOGLE_API_KEY', undefined); }); afterEach(() => { vi.unstubAllEnvs(); }); it.each([ { description: 'should return null for LOGIN_WITH_GOOGLE', authType: AuthType.LOGIN_WITH_GOOGLE, envs: {}, expected: null, }, { description: 'should return null for COMPUTE_ADC', authType: AuthType.COMPUTE_ADC, envs: {}, expected: null, }, { description: 'should return null for USE_GEMINI if GEMINI_API_KEY is set', authType: AuthType.USE_GEMINI, envs: { GEMINI_API_KEY: 'test-key' }, expected: null, }, { description: 'should return an error message for USE_GEMINI if GEMINI_API_KEY is not set', authType: AuthType.USE_GEMINI, envs: {}, expected: 'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' + 'Update your environment and try again (no reload needed if using .env)!', }, { description: 'should return null for USE_VERTEX_AI if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', authType: AuthType.USE_VERTEX_AI, envs: { GOOGLE_CLOUD_PROJECT: 'test-project', GOOGLE_CLOUD_LOCATION: 'test-location', }, expected: null, }, { description: 'should return null for USE_VERTEX_AI if GOOGLE_API_KEY is set', authType: AuthType.USE_VERTEX_AI, envs: { GOOGLE_API_KEY: 'test-api-key' }, expected: null, }, { description: 'should return an error message for USE_VERTEX_AI if no required environment variables are set', authType: AuthType.USE_VERTEX_AI, envs: {}, expected: 'When using Vertex AI, you must specify either:\n' + '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + '• GOOGLE_API_KEY environment variable (if using express mode).\n' + 'Update your environment and try again (no reload needed if using .env)!', }, { description: 'should return an error message for an invalid auth method', // eslint-disable-next-line @typescript-eslint/no-explicit-any authType: 'invalid-method' as any, envs: {}, expected: 'Invalid auth method selected.', }, ])('$description', ({ authType, envs, expected }) => { for (const [key, value] of Object.entries(envs)) { vi.stubEnv(key, value as string); } expect(validateAuthMethod(authType)).toBe(expected); }); }); ================================================ FILE: packages/cli/src/config/auth.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { AuthType } from '@google/gemini-cli-core'; import { loadEnvironment, loadSettings } from './settings.js'; export function validateAuthMethod(authMethod: string): string | null { loadEnvironment(loadSettings().merged, process.cwd()); if ( authMethod === AuthType.LOGIN_WITH_GOOGLE || authMethod === AuthType.COMPUTE_ADC ) { return null; } if (authMethod === AuthType.USE_GEMINI) { if (!process.env['GEMINI_API_KEY']) { return ( 'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' + 'Update your environment and try again (no reload needed if using .env)!' ); } return null; } if (authMethod === AuthType.USE_VERTEX_AI) { const hasVertexProjectLocationConfig = !!process.env['GOOGLE_CLOUD_PROJECT'] && !!process.env['GOOGLE_CLOUD_LOCATION']; const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY']; if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) { return ( 'When using Vertex AI, you must specify either:\n' + '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + '• GOOGLE_API_KEY environment variable (if using express mode).\n' + 'Update your environment and try again (no reload needed if using .env)!' ); } return null; } return 'Invalid auth method selected.'; } ================================================ FILE: packages/cli/src/config/config.integration.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; import type { ConfigParameters } from '@google/gemini-cli-core'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS, } from '@google/gemini-cli-core'; import { createTestMergedSettings } from './settings.js'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; export const server = setupServer(); // TODO(richieforeman): Consider moving this to test setup globally. beforeAll(() => { server.listen({}); }); afterEach(() => { server.resetHandlers(); }); afterAll(() => { server.close(); }); const CLEARCUT_URL = 'https://play.googleapis.com/log'; // Mock file discovery service and tool registry vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, FileDiscoveryService: vi.fn().mockImplementation(() => ({ initialize: vi.fn(), })), createToolRegistry: vi.fn().mockResolvedValue({}), }; }); describe('Configuration Integration Tests', () => { let tempDir: string; beforeEach(() => { server.resetHandlers(http.post(CLEARCUT_URL, () => HttpResponse.text())); tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-')); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.clearAllMocks(); }); afterEach(() => { vi.unstubAllEnvs(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } }); describe('File Filtering and Configuration', () => { it.each([ { description: 'should load default file filtering settings when fileFiltering is missing', fileFiltering: undefined, expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, }, { description: 'should load custom file filtering settings from configuration', fileFiltering: { respectGitIgnore: false }, expected: false, }, { description: 'should respect file filtering settings from configuration', fileFiltering: { respectGitIgnore: true }, expected: true, }, { description: 'should handle empty fileFiltering object gracefully and use defaults', fileFiltering: {}, expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, }, ])('$description', async ({ fileFiltering, expected }) => { const configParams: ConfigParameters = { sessionId: 'test-session', cwd: '/tmp', model: 'test-model', embeddingModel: 'test-embedding-model', sandbox: undefined, targetDir: tempDir, debugMode: false, fileFiltering, }; const config = new Config(configParams); expect(config.getFileFilteringRespectGitIgnore()).toBe(expected); }); }); describe('Real-world Configuration Scenarios', () => { it.each([ { description: 'should handle a security-focused configuration', respectGitIgnore: true, }, { description: 'should handle a CI/CD environment configuration', respectGitIgnore: false, }, ])('$description', async ({ respectGitIgnore }) => { const configParams: ConfigParameters = { sessionId: 'test-session', cwd: '/tmp', model: 'test-model', embeddingModel: 'test-embedding-model', sandbox: undefined, targetDir: tempDir, debugMode: false, fileFiltering: { respectGitIgnore, }, }; const config = new Config(configParams); expect(config.getFileFilteringRespectGitIgnore()).toBe(respectGitIgnore); }); }); describe('Checkpointing Configuration', () => { it('should enable checkpointing when the setting is true', async () => { const configParams: ConfigParameters = { sessionId: 'test-session', cwd: '/tmp', model: 'test-model', embeddingModel: 'test-embedding-model', sandbox: undefined, targetDir: tempDir, debugMode: false, checkpointing: true, }; const config = new Config(configParams); expect(config.getCheckpointingEnabled()).toBe(true); }); }); describe('Approval Mode Integration Tests', () => { let parseArguments: typeof import('./config.js').parseArguments; beforeEach(async () => { // Import the argument parsing function for integration testing const { parseArguments: parseArgs } = await import('./config.js'); parseArguments = parseArgs; }); it.each([ { description: 'should parse --approval-mode=auto_edit correctly', argv: [ 'node', 'script.js', '--approval-mode', 'auto_edit', '-p', 'test', ], expected: { approvalMode: 'auto_edit', prompt: 'test', yolo: false }, }, { description: 'should parse --approval-mode=yolo correctly', argv: ['node', 'script.js', '--approval-mode', 'yolo', '-p', 'test'], expected: { approvalMode: 'yolo', prompt: 'test', yolo: false }, }, { description: 'should parse --approval-mode=default correctly', argv: ['node', 'script.js', '--approval-mode', 'default', '-p', 'test'], expected: { approvalMode: 'default', prompt: 'test', yolo: false }, }, { description: 'should parse legacy --yolo flag correctly', argv: ['node', 'script.js', '--yolo', '-p', 'test'], expected: { yolo: true, approvalMode: undefined, prompt: 'test' }, }, { description: 'should handle no approval mode arguments', argv: ['node', 'script.js', '-p', 'test'], expected: { approvalMode: undefined, yolo: false, prompt: 'test' }, }, ])('$description', async ({ argv, expected }) => { const originalArgv = process.argv; try { process.argv = argv; const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.yolo).toBe(expected.yolo); } finally { process.argv = originalArgv; } }); it.each([ { description: 'should reject invalid approval mode values', argv: ['node', 'script.js', '--approval-mode', 'invalid_mode'], }, { description: 'should reject conflicting --yolo and --approval-mode flags', argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'], }, ])('$description', async ({ argv }) => { const originalArgv = process.argv; try { process.argv = argv; await expect( parseArguments(createTestMergedSettings()), ).rejects.toThrow(); } finally { process.argv = originalArgv; } }); }); }); ================================================ FILE: packages/cli/src/config/config.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { DEFAULT_FILE_FILTERING_OPTIONS, OutputFormat, SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, WEB_FETCH_TOOL_NAME, ASK_USER_TOOL_NAME, type ExtensionLoader, debugLogger, ApprovalMode, type MCPServerConfig, type GeminiCLIExtension, Storage, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { type Settings, type MergedSettings, createTestMergedSettings, } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted })); vi.mock('./sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn(async () => undefined), })); vi.mock('../commands/utils.js', () => ({ exitCli: vi.fn(), })); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); const pathMod = await import('node:path'); const mockHome = pathMod.resolve(pathMod.sep, 'mock', 'home', 'user'); const MOCK_CWD1 = process.cwd(); const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project'); const mockPaths = new Set([ MOCK_CWD1, MOCK_CWD2, pathMod.resolve(pathMod.sep, 'cli', 'path1'), pathMod.resolve(pathMod.sep, 'settings', 'path1'), pathMod.join(mockHome, 'settings', 'path2'), pathMod.join(MOCK_CWD2, 'cli', 'path2'), pathMod.join(MOCK_CWD2, 'settings', 'path3'), ]); return { ...actualFs, mkdirSync: vi.fn((p) => { mockPaths.add(p.toString()); }), writeFileSync: vi.fn(), existsSync: vi.fn((p) => mockPaths.has(p.toString())), statSync: vi.fn((p) => { if (mockPaths.has(p.toString())) { return { isDirectory: () => true } as unknown as import('fs').Stats; } return actualFs.statSync(p as unknown as string); }), realpathSync: vi.fn((p) => p), }; }); vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, homedir: vi.fn(() => path.resolve(path.sep, 'mock', 'home', 'user')), }; }); vi.mock('open', () => ({ default: vi.fn(), })); vi.mock('read-package-up', () => ({ readPackageUp: vi.fn(() => Promise.resolve({ packageJson: { version: 'test-version' } }), ), })); vi.mock('@google/gemini-cli-core', async () => { const actualServer = await vi.importActual( '@google/gemini-cli-core', ); return { ...actualServer, IdeClient: { getInstance: vi.fn().mockResolvedValue({ getConnectionStatus: vi.fn(), initialize: vi.fn(), shutdown: vi.fn(), }), }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( ( cwd, dirs, fileService, extensionLoader: ExtensionLoader, _folderTrust, _importFormat, _fileFilteringOptions, _maxDirs, ) => { const extensionPaths = extensionLoader?.getExtensions?.()?.flatMap((e) => e.contextFiles) || []; return Promise.resolve({ memoryContent: extensionPaths.join(',') || '', fileCount: extensionPaths?.length || 0, filePaths: extensionPaths, }); }, ), DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, respectGeminiIgnore: true, customIgnoreFilePaths: [], }, DEFAULT_FILE_FILTERING_OPTIONS: { respectGitIgnore: true, respectGeminiIgnore: true, customIgnoreFilePaths: [], }, createPolicyEngineConfig: vi.fn(async () => ({ rules: [], checkers: [], defaultDecision: ServerConfig.PolicyDecision.ASK_USER, approvalMode: ServerConfig.ApprovalMode.DEFAULT, })), getAdminErrorMessage: vi.fn( (_feature) => `YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`, ), isHeadlessMode: vi.fn((opts) => { if (process.env['VITEST'] === 'true') { return ( !!opts?.prompt || (!!process.stdin && !process.stdin.isTTY) || (!!process.stdout && !process.stdout.isTTY) ); } return ( !!opts?.prompt || process.env['CI'] === 'true' || process.env['GITHUB_ACTIONS'] === 'true' || (!!process.stdin && !process.stdin.isTTY) || (!!process.stdout && !process.stdout.isTTY) ); }), }; }); vi.mock('./extension-manager.js', () => { const ExtensionManager = vi.fn(); ExtensionManager.prototype.loadExtensions = vi.fn(); ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); return { ExtensionManager }; }); // Global setup to ensure clean environment for all tests in this file const originalArgv = process.argv; const originalGeminiModel = process.env['GEMINI_MODEL']; const originalStdoutIsTTY = process.stdout.isTTY; const originalStdinIsTTY = process.stdin.isTTY; beforeEach(() => { delete process.env['GEMINI_MODEL']; // Restore ExtensionManager mocks by re-assigning them ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); ExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); // Default to interactive mode for tests unless otherwise specified Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, writable: true, }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, writable: true, }); }); afterEach(() => { process.argv = originalArgv; if (originalGeminiModel !== undefined) { process.env['GEMINI_MODEL'] = originalGeminiModel; } else { delete process.env['GEMINI_MODEL']; } Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true, writable: true, }); Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true, writable: true, }); }); describe('parseArguments', () => { it.each([ { description: 'long flags', argv: [ 'node', 'script.js', '--prompt', 'test prompt', '--prompt-interactive', 'interactive prompt', ], }, { description: 'short flags', argv: [ 'node', 'script.js', '-p', 'test prompt', '-i', 'interactive prompt', ], }, ])( 'should throw an error when using conflicting prompt flags ($description)', async ({ argv }) => { process.argv = argv; const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', ), ); mockExit.mockRestore(); mockConsoleError.mockRestore(); }, ); it.each([ { description: 'should allow --prompt without --prompt-interactive', argv: ['node', 'script.js', '--prompt', 'test prompt'], expected: { prompt: 'test prompt', promptInteractive: undefined }, }, { description: 'should allow --prompt-interactive without --prompt', argv: ['node', 'script.js', '--prompt-interactive', 'interactive prompt'], expected: { prompt: undefined, promptInteractive: 'interactive prompt' }, }, { description: 'should allow -i flag as alias for --prompt-interactive', argv: ['node', 'script.js', '-i', 'interactive prompt'], expected: { prompt: undefined, promptInteractive: 'interactive prompt' }, }, ])('$description', async ({ argv, expected }) => { process.argv = argv; const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive); }); describe('positional arguments and @commands', () => { beforeEach(() => { // Default to headless mode for these tests as they mostly expect one-shot behavior process.stdin.isTTY = false; Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true, writable: true, }); }); it.each([ { description: 'should convert positional query argument to prompt by default', argv: ['node', 'script.js', 'Hi Gemini'], expectedQuery: 'Hi Gemini', expectedModel: undefined, debug: false, }, { description: 'should map @path to prompt (one-shot) when it starts with @', argv: ['node', 'script.js', '@path ./file.md'], expectedQuery: '@path ./file.md', expectedModel: undefined, debug: false, }, { description: 'should map @path to prompt even when config flags are present', argv: [ 'node', 'script.js', '@path', './file.md', '--model', 'gemini-2.5-pro', ], expectedQuery: '@path ./file.md', expectedModel: 'gemini-2.5-pro', debug: false, }, { description: 'maps unquoted positional @path + arg to prompt (one-shot)', argv: ['node', 'script.js', '@path', './file.md'], expectedQuery: '@path ./file.md', expectedModel: undefined, debug: false, }, { description: 'should handle multiple @path arguments in a single command (one-shot)', argv: [ 'node', 'script.js', '@path', './file1.md', '@path', './file2.md', ], expectedQuery: '@path ./file1.md @path ./file2.md', expectedModel: undefined, debug: false, }, { description: 'should handle mixed quoted and unquoted @path arguments (one-shot)', argv: [ 'node', 'script.js', '@path ./file1.md', '@path', './file2.md', 'additional text', ], expectedQuery: '@path ./file1.md @path ./file2.md additional text', expectedModel: undefined, debug: false, }, { description: 'should map @path to prompt with ambient flags (debug)', argv: ['node', 'script.js', '@path', './file.md', '--debug'], expectedQuery: '@path ./file.md', expectedModel: undefined, debug: true, }, { description: 'should map @include to prompt (one-shot)', argv: ['node', 'script.js', '@include src/'], expectedQuery: '@include src/', expectedModel: undefined, debug: false, }, { description: 'should map @search to prompt (one-shot)', argv: ['node', 'script.js', '@search pattern'], expectedQuery: '@search pattern', expectedModel: undefined, debug: false, }, { description: 'should map @web to prompt (one-shot)', argv: ['node', 'script.js', '@web query'], expectedQuery: '@web query', expectedModel: undefined, debug: false, }, { description: 'should map @git to prompt (one-shot)', argv: ['node', 'script.js', '@git status'], expectedQuery: '@git status', expectedModel: undefined, debug: false, }, { description: 'should handle @command with leading whitespace', argv: ['node', 'script.js', ' @path ./file.md'], expectedQuery: ' @path ./file.md', expectedModel: undefined, debug: false, }, ])( '$description', async ({ argv, expectedQuery, expectedModel, debug }) => { process.argv = argv; const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.query).toBe(expectedQuery); expect(parsedArgs.prompt).toBe(expectedQuery); expect(parsedArgs.promptInteractive).toBeUndefined(); if (expectedModel) { expect(parsedArgs.model).toBe(expectedModel); } if (debug) { expect(parsedArgs.debug).toBe(true); } }, ); it('should include a startup message when converting positional query to interactive prompt', async () => { process.stdin.isTTY = true; Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, writable: true, }); process.argv = ['node', 'script.js', 'hello']; try { const argv = await parseArguments(createTestMergedSettings()); expect(argv.startupMessages).toContain( 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); } finally { // beforeEach handles resetting } }); }); it.each([ { description: 'long flags', argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'], }, { description: 'short flags', argv: ['node', 'script.js', '-y', '--approval-mode', 'yolo'], }, ])( 'should throw an error when using conflicting yolo/approval-mode flags ($description)', async ({ argv }) => { process.argv = argv; const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', ), ); mockExit.mockRestore(); mockConsoleError.mockRestore(); }, ); it.each([ { description: 'should allow --approval-mode without --yolo', argv: ['node', 'script.js', '--approval-mode', 'auto_edit'], expected: { approvalMode: 'auto_edit', yolo: false }, }, { description: 'should allow --yolo without --approval-mode', argv: ['node', 'script.js', '--yolo'], expected: { approvalMode: undefined, yolo: true }, }, ])('$description', async ({ argv, expected }) => { process.argv = argv; const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.yolo).toBe(expected.yolo); }); it('should reject invalid --approval-mode values', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'invalid']; const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); const debugErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), ); expect(mockConsoleError).toHaveBeenCalled(); mockExit.mockRestore(); mockConsoleError.mockRestore(); debugErrorSpy.mockRestore(); }); it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => { const originalIsTTY = process.stdin.isTTY; process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--resume', 'session-id']; try { const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe('session-id'); } finally { process.stdin.isTTY = originalIsTTY; } }); it('should return RESUME_LATEST constant when --resume is passed without a value', async () => { const originalIsTTY = process.stdin.isTTY; process.stdin.isTTY = true; // Make it interactive to avoid validation error process.argv = ['node', 'script.js', '--resume']; try { const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe(RESUME_LATEST); expect(argv.resume).toBe('latest'); } finally { process.stdin.isTTY = originalIsTTY; } }); it('should support comma-separated values for --allowed-tools', async () => { process.argv = [ 'node', 'script.js', '--allowed-tools', 'read_file,ShellTool(git status)', ]; const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']); }); it('should support comma-separated values for --allowed-mcp-server-names', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server1,server2', ]; const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']); }); it('should support comma-separated values for --extensions', async () => { process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2']; const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['ext1', 'ext2']); }); it('should correctly parse positional arguments when flags with arguments are present', async () => { process.argv = [ 'node', 'script.js', '--model', 'test-model-string', 'my-positional-arg', ]; const argv = await parseArguments(createTestMergedSettings()); expect(argv.model).toBe('test-model-string'); expect(argv.query).toBe('my-positional-arg'); }); it('should handle long positional prompts with multiple flags', async () => { process.argv = [ 'node', 'script.js', '-e', 'none', '--approval-mode=auto_edit', '--allowed-tools=ShellTool', '--allowed-tools=ShellTool(whoami)', '--allowed-tools=ShellTool(wc)', 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ]; const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['none']); expect(argv.approvalMode).toBe('auto_edit'); expect(argv.allowedTools).toEqual([ 'ShellTool', 'ShellTool(whoami)', 'ShellTool(wc)', ]); expect(argv.query).toBe( 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ); }); it('should set isCommand to true for mcp command', async () => { process.argv = ['node', 'script.js', 'mcp', 'list']; const argv = await parseArguments(createTestMergedSettings()); expect(argv.isCommand).toBe(true); }); it('should set isCommand to true for extensions command', async () => { process.argv = ['node', 'script.js', 'extensions', 'list']; // Extensions command uses experimental settings const settings = createTestMergedSettings({ experimental: { extensionManagement: true }, }); const argv = await parseArguments(settings); expect(argv.isCommand).toBe(true); }); it('should set isCommand to true for skills command', async () => { process.argv = ['node', 'script.js', 'skills', 'list']; // Skills command enabled by default or via experimental const settings = createTestMergedSettings({ skills: { enabled: true }, }); const argv = await parseArguments(settings); expect(argv.isCommand).toBe(true); }); it('should set isCommand to true for hooks command', async () => { process.argv = ['node', 'script.js', 'hooks', 'migrate']; // Hooks command enabled via hooksConfig settings const settings = createTestMergedSettings({ hooksConfig: { enabled: true }, }); const argv = await parseArguments(settings); expect(argv.isCommand).toBe(true); }); }); describe('loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); describe('Proxy configuration', () => { const originalProxyEnv: { [key: string]: string | undefined } = {}; const proxyEnvVars = [ 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', ]; beforeEach(() => { for (const key of proxyEnvVars) { originalProxyEnv[key] = process.env[key]; delete process.env[key]; } }); afterEach(() => { for (const key of proxyEnvVars) { if (originalProxyEnv[key]) { process.env[key] = originalProxyEnv[key]; } else { delete process.env[key]; } } }); it(`should leave proxy to empty by default`, async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); const proxy_url = 'http://localhost:7890'; const testCases = [ { input: { env_name: 'https_proxy', proxy_url, }, expected: proxy_url, }, { input: { env_name: 'http_proxy', proxy_url, }, expected: proxy_url, }, { input: { env_name: 'HTTPS_PROXY', proxy_url, }, expected: proxy_url, }, { input: { env_name: 'HTTP_PROXY', proxy_url, }, expected: proxy_url, }, ]; testCases.forEach(({ input, expected }) => { it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { vi.stubEnv(input.env_name, input.proxy_url); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); }); }); it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => { vi.stubEnv( 'GEMINI_CLI_IDE_WORKSPACE_PATH', ['/project/folderA', '/project/folderB'].join(path.delimiter), ); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const dirs = config.getPendingIncludeDirectories(); expect(dirs).toContain('/project/folderA'); expect(dirs).toContain('/project/folderB'); }); it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { const resolveToRealPathSpy = vi .spyOn(ServerConfig, 'resolveToRealPath') .mockImplementation((p) => { if (p.toString().includes('restricted')) { const err = new Error('EACCES: permission denied'); (err as NodeJS.ErrnoException).code = 'EACCES'; throw err; } return p.toString(); }); vi.stubEnv( 'GEMINI_CLI_IDE_WORKSPACE_PATH', ['/project/folderA', '/nonexistent/restricted/folder'].join( path.delimiter, ), ); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const dirs = config.getPendingIncludeDirectories(); expect(dirs).toContain('/project/folderA'); expect(dirs).not.toContain('/nonexistent/restricted/folder'); resolveToRealPathSpy.mockRestore(); }); it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFileFilteringRespectGitIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, ); expect(config.getFileFilteringRespectGeminiIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore, ); expect(config.getCustomIgnoreFilePaths()).toEqual( DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths, ); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should be non-interactive when isCommand is set', async () => { process.argv = ['node', 'script.js', 'mcp', 'list']; const argv = await parseArguments(createTestMergedSettings()); argv.isCommand = true; // explicitly set it as if middleware ran (it does in parseArguments but we want to be sure for this isolated test if we were mocking argv) // reset tty for this test process.stdin.isTTY = true; const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); }); describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { beforeEach(() => { vi.resetAllMocks(); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); // Restore ExtensionManager mocks that were reset ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); ExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue(undefined); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); // Other common mocks would be reset here. }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { jitContext: false }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', id: 'ext1-id', version: '1.0.0', contextFiles: ['/path/to/ext1/GEMINI.md'], isActive: true, }, { path: '/path/to/ext2', name: 'ext2', id: 'ext2-id', version: '1.0.0', contextFiles: [], isActive: true, }, { path: '/path/to/ext3', name: 'ext3', id: 'ext3-id', version: '1.0.0', contextFiles: [ '/path/to/ext3/context1.md', '/path/to/ext3/context2.md', ], isActive: true, }, ]); const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], expect.any(Object), expect.any(ExtensionManager), true, 'tree', expect.objectContaining({ respectGitIgnore: true, respectGeminiIgnore: true, }), 200, // maxDirs ); }); it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => { process.argv = ['node', 'script.js']; const includeDir = path.resolve(path.sep, 'path', 'to', 'include'); const settings = createTestMergedSettings({ experimental: { jitContext: false }, context: { includeDirectories: [includeDir], loadMemoryFromIncludeDirectories: true, }, }); const argv = await parseArguments(settings); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [includeDir], expect.any(Object), expect.any(ExtensionManager), true, 'tree', expect.objectContaining({ respectGitIgnore: true, respectGeminiIgnore: true, }), 200, ); }); it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { jitContext: false }, context: { includeDirectories: ['/path/to/include'], loadMemoryFromIncludeDirectories: false, }, }); const argv = await parseArguments(settings); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], expect.any(Object), expect.any(ExtensionManager), true, 'tree', expect.objectContaining({ respectGitIgnore: true, respectGeminiIgnore: true, }), 200, ); }); }); describe('mergeMcpServers', () => { it('should not modify the original settings object', async () => { const settings = createTestMergedSettings({ mcpServers: { 'test-server': { url: 'http://localhost:8080', }, }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', id: 'ext1-id', version: '1.0.0', mcpServers: { 'ext1-server': { url: 'http://localhost:8081', }, }, contextFiles: [], isActive: true, }, ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); describe('mergeExcludeTools', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); process.stdin.isTTY = true; }); afterEach(() => { process.stdin.isTTY = originalIsTTY; }); it('should merge excludeTools from settings and extensions', async () => { const settings = createTestMergedSettings({ tools: { exclude: ['tool1', 'tool2'] }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', id: 'ext1-id', version: '1.0.0', excludeTools: ['tool3', 'tool4'], contextFiles: [], isActive: true, }, { path: '/path/to/ext2', name: 'ext2', id: 'ext2-id', version: '1.0.0', excludeTools: ['tool5'], contextFiles: [], isActive: true, }, ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( settings, 'test-session', argv, ); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']), ); expect(config.getExcludeTools()).toHaveLength(5); }); it('should handle overlapping excludeTools between settings and extensions', async () => { const settings = createTestMergedSettings({ tools: { exclude: ['tool1', 'tool2'] }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', id: 'ext1-id', version: '1.0.0', excludeTools: ['tool2', 'tool3'], contextFiles: [], isActive: true, }, ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3']), ); expect(config.getExcludeTools()).toHaveLength(3); }); it('should handle overlapping excludeTools between extensions', async () => { const settings = createTestMergedSettings({ tools: { exclude: ['tool1'] }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', id: 'ext1-id', version: '1.0.0', excludeTools: ['tool2', 'tool3'], contextFiles: [], isActive: true, }, { path: '/path/to/ext2', name: 'ext2', id: 'ext2-id', version: '1.0.0', excludeTools: ['tool3', 'tool4'], contextFiles: [], isActive: true, }, ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4']), ); expect(config.getExcludeTools()).toHaveLength(4); }); it('should return an empty array when no excludeTools are specified and it is interactive', async () => { process.stdin.isTTY = true; const settings = createTestMergedSettings(); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set([])); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; const settings = createTestMergedSettings(); process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME])); }); it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ tools: { exclude: ['tool1', 'tool2'] }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); expect(config.getExcludeTools()).toHaveLength(2); }); it('should handle extensions with excludeTools but no settings', async () => { const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', id: 'ext1-id', version: '1.0.0', excludeTools: ['tool1', 'tool2'], contextFiles: [], isActive: true, }, ]); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); expect(config.getExcludeTools()).toHaveLength(2); }); it('should not modify the original settings object', async () => { const settings = createTestMergedSettings({ tools: { exclude: ['tool1'] }, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', id: 'ext1-id', version: '1.0.0', excludeTools: ['tool2'], contextFiles: [], isActive: true, }, ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); describe('Approval mode tool exclusion logic', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { process.stdin.isTTY = false; // Ensure non-interactive mode vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.stdin.isTTY = originalIsTTY; }); it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => { process.argv = [ 'node', 'script.js', '--approval-mode', 'default', '-p', 'test', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => { process.argv = [ 'node', 'script.js', '--approval-mode', 'auto_edit', '-p', 'test', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => { process.argv = [ 'node', 'script.js', '--approval-mode', 'yolo', '-p', 'test', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => { process.argv = [ 'node', 'script.js', '--approval-mode', 'plan', '-p', 'test', ]; const settings = createTestMergedSettings({ experimental: { plan: true, }, }); const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should exclude only ask_user in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => { process.stdin.isTTY = true; // Interactive mode const testCases = [ { args: ['node', 'script.js'] }, // default { args: ['node', 'script.js', '--approval-mode', 'default'] }, { args: ['node', 'script.js', '--approval-mode', 'auto_edit'] }, { args: ['node', 'script.js', '--approval-mode', 'yolo'] }, { args: ['node', 'script.js', '--yolo'] }, ]; for (const testCase of testCases) { process.argv = testCase.args; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).not.toContain(ASK_USER_TOOL_NAME); } }); it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => { process.argv = [ 'node', 'script.js', '--approval-mode', 'auto_edit', '-p', 'test', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ tools: { exclude: ['custom_tool'] }, }); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain('custom_tool'); // From settings expect(excludedTools).not.toContain(SHELL_TOOL_NAME); // No longer from approval mode expect(excludedTools).not.toContain(EDIT_TOOL_NAME); // Should be allowed in auto_edit expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ security: { disableYoloMode: true, }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); it('should throw an error for invalid approval mode values in loadCliConfig', async () => { // Create a mock argv with an invalid approval mode that bypasses argument parsing validation const invalidArgv: Partial & { approvalMode: string } = { approvalMode: 'invalid_mode', promptInteractive: '', prompt: '', yolo: false, }; const settings = createTestMergedSettings(); await expect( loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default', ); }); it('should fall back to default approval mode if plan mode is requested but not enabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'plan', }, experimental: { plan: false, }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should allow plan approval mode if experimental plan is enabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'plan', }, experimental: { plan: true, }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); }); describe('loadCliConfig with allowed-mcp-server-names', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); const baseSettings = createTestMergedSettings({ mcpServers: { server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }, }); it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); it('should allow only the specified MCP server', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server1', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); it('should allow multiple specified MCP servers', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server1', '--allowed-mcp-server-names', 'server3', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']); }); it('should handle server names that do not exist', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server1', '--allowed-mcp-server-names', 'server4', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']); }); it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['']); }); it('should read allowMCPServers from settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); }); it('should read excludeMCPServers from settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']); }); it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server1', 'server2'], }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); expect(config.getBlockedMcpServers()).toEqual(['server1']); }); it('should prioritize mcp server flag if set', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server1', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server2'], }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); it('should prioritize CLI flag over both allowed and excluded settings', async () => { process.argv = [ 'node', 'script.js', '--allowed-mcp-server-names', 'server2', '--allowed-mcp-server-names', 'server3', ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'], // Should be ignored excluded: ['server3'], // Should be ignored }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']); expect(config.getBlockedMcpServers()).toEqual([]); }); }); describe('loadCliConfig with admin.mcp.config', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); const localMcpServers: Record = { serverA: { command: 'npx', args: ['-y', '@mcp/server-a'], env: { KEY: 'VALUE' }, cwd: '/local/cwd', trust: false, }, serverB: { command: 'npx', args: ['-y', '@mcp/server-b'], trust: false, }, }; const baseSettings = createTestMergedSettings({ mcp: { serverCommand: 'npx -y @mcp/default-server' }, mcpServers: localMcpServers, }); it('should use local configuration if admin allowlist is empty', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ mcp: baseSettings.mcp, mcpServers: localMcpServers, admin: { ...baseSettings.admin, mcp: { enabled: true, config: {} }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual(localMcpServers); expect(config.getMcpServerCommand()).toBe('npx -y @mcp/default-server'); }); it('should ignore locally configured servers not present in the allowlist', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const adminAllowlist: Record = { serverA: { type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, }, }; const settings = createTestMergedSettings({ mcp: baseSettings.mcp, mcpServers: localMcpServers, admin: { ...baseSettings.admin, mcp: { enabled: true, config: adminAllowlist }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).toHaveProperty('serverA'); expect(mergedServers).not.toHaveProperty('serverB'); }); it('should clear command, args, env, and cwd for present servers', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const adminAllowlist: Record = { serverA: { type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, }, }; const settings = createTestMergedSettings({ mcpServers: localMcpServers, admin: { ...baseSettings.admin, mcp: { enabled: true, config: adminAllowlist }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); const serverA = config.getMcpServers()?.['serverA']; expect(serverA).toEqual({ ...localMcpServers['serverA'], type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, command: undefined, args: undefined, env: undefined, cwd: undefined, httpUrl: undefined, tcp: undefined, }); }); it('should not initialize a server if it is in allowlist but missing locally', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const adminAllowlist: Record = { serverC: { type: 'sse', url: 'https://admin-server-c.com/sse', trust: true, }, }; const settings = createTestMergedSettings({ mcpServers: localMcpServers, admin: { ...baseSettings.admin, mcp: { enabled: true, config: adminAllowlist }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); const mergedServers = config.getMcpServers() ?? {}; expect(mergedServers).not.toHaveProperty('serverC'); expect(Object.keys(mergedServers)).toHaveLength(0); }); it('should merge local fields and prefer admin tool filters', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const adminAllowlist: Record = { serverA: { type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, includeTools: ['admin_tool'], }, }; const localMcpServersWithTools: Record = { serverA: { ...localMcpServers['serverA'], includeTools: ['local_tool'], timeout: 1234, }, }; const settings = createTestMergedSettings({ mcpServers: localMcpServersWithTools, admin: { ...baseSettings.admin, mcp: { enabled: true, config: adminAllowlist }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); const serverA = (config.getMcpServers() ?? {})['serverA']; expect(serverA).toMatchObject({ timeout: 1234, includeTools: ['admin_tool'], type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, }); expect(serverA).not.toHaveProperty('command'); expect(serverA).not.toHaveProperty('args'); expect(serverA).not.toHaveProperty('env'); expect(serverA).not.toHaveProperty('cwd'); expect(serverA).not.toHaveProperty('httpUrl'); expect(serverA).not.toHaveProperty('tcp'); }); it('should use local tool filters when admin does not define them', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const adminAllowlist: Record = { serverA: { type: 'sse', url: 'https://admin-server-a.com/sse', trust: true, }, }; const localMcpServersWithTools: Record = { serverA: { ...localMcpServers['serverA'], includeTools: ['local_tool'], }, }; const settings = createTestMergedSettings({ mcpServers: localMcpServersWithTools, admin: { ...baseSettings.admin, mcp: { enabled: true, config: adminAllowlist }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); const serverA = config.getMcpServers()?.['serverA']; expect(serverA?.includeTools).toEqual(['local_tool']); }); }); describe('loadCliConfig model selection', () => { beforeEach(() => { vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.resetAllMocks(); }); it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, }), 'test-session', argv, ); expect(config.getModel()).toBe('gemini-2.5-pro'); }); it('uses the default gemini model if nothing is set', async () => { process.argv = ['node', 'script.js']; // No model set. const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ // No model set. }), 'test-session', argv, ); expect(config.getModel()).toBe('auto-gemini-3'); }); it('always prefers model from argv', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, }), 'test-session', argv, ); expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); it('selects the model from argv if provided', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ // No model provided via settings. }), 'test-session', argv, ); expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); it('selects the default auto model if provided via auto alias', async () => { process.argv = ['node', 'script.js', '--model', 'auto']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ // No model provided via settings. }), 'test-session', argv, ); expect(config.getModel()).toBe('auto-gemini-3'); }); }); describe('loadCliConfig folderTrust', () => { let originalVitest: string | undefined; let originalIntegrationTest: string | undefined; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); originalVitest = process.env['VITEST']; originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST']; delete process.env['VITEST']; delete process.env['GEMINI_CLI_INTEGRATION_TEST']; }); afterEach(() => { if (originalVitest !== undefined) { process.env['VITEST'] = originalVitest; } if (originalIntegrationTest !== undefined) { process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest; } vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should be false when folderTrust is false', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ security: { folderTrust: { enabled: false, }, }, }); const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); it('should be true when folderTrust is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ security: { folderTrust: { enabled: true, }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); it('should be true by default', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); }); describe('loadCliConfig with includeDirectories', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue( path.resolve(path.sep, 'mock', 'home', 'user'), ); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(process, 'cwd').mockReturnValue( path.resolve(path.sep, 'home', 'user', 'project'), ); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.restoreAllMocks(); }); it.skip('should combine and resolve paths from settings and CLI arguments', async () => { const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); process.argv = [ 'node', 'script.js', '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, ]; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ context: { includeDirectories: [ path.resolve(path.sep, 'settings', 'path1'), path.join(os.homedir(), 'settings', 'path2'), path.join(mockCwd, 'settings', 'path3'), ], }, }); const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, path.resolve(path.sep, 'cli', 'path1'), path.join(mockCwd, 'cli', 'path2'), path.resolve(path.sep, 'settings', 'path1'), path.join(os.homedir(), 'settings', 'path2'), path.join(mockCwd, 'settings', 'path3'), ]; const directories = config.getWorkspaceContext().getDirectories(); expect(directories).toEqual([mockCwd]); expect(config.getPendingIncludeDirectories()).toEqual( expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)), ); expect(config.getPendingIncludeDirectories()).toHaveLength( expected.length - 1, ); }); }); describe('loadCliConfig compressionThreshold', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should pass settings to the core config', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ model: { compressionThreshold: 0.5, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(await config.getCompressionThreshold()).toBe(0.5); }); it('should have default compressionThreshold if not in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(await config.getCompressionThreshold()).toBe(0.5); }); }); describe('loadCliConfig useRipgrep', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should be true by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); it('should be false when useRipgrep is set to false in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ tools: { useRipgrep: false } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); it('should be true when useRipgrep is explicitly set to true in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ tools: { useRipgrep: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); }); describe('loadCliConfig directWebFetch', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should be false by default when directWebFetch is not set in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getDirectWebFetch()).toBe(false); }); it('should be true when directWebFetch is set to true in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ experimental: { directWebFetch: true, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getDirectWebFetch()).toBe(true); }); }); describe('screenReader configuration', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should use screenReader value from settings if CLI flag is not present (settings true)', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: true } }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should use screenReader value from settings if CLI flag is not present (settings false)', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); it('should prioritize --screen-reader CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--screen-reader']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should be false by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); }); describe('loadCliConfig tool exclusions', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.stdin.isTTY = true; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should not exclude interactive tools in interactive mode without YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).not.toContain('ask_user'); }); it('should not exclude interactive tools in interactive mode with YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).not.toContain('ask_user'); }); it('should exclude interactive tools in non-interactive mode without YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).toContain('ask_user'); }); it('should exclude only ask_user in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).toContain('ask_user'); }); it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ 'node', 'script.js', '-p', 'test', '--allowed-tools', 'ShellTool', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); it('should not exclude web-fetch in non-interactive mode at config level', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); it('should not exclude web-fetch in non-interactive mode when allowed', async () => { process.stdin.isTTY = false; process.argv = [ 'node', 'script.js', '-p', 'test', '--allowed-tools', WEB_FETCH_TOOL_NAME, ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); it('should not exclude shell tool in non-interactive mode when --allowed-tools="run_shell_command" is set', async () => { process.stdin.isTTY = false; process.argv = [ 'node', 'script.js', '-p', 'test', '--allowed-tools', 'run_shell_command', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool(wc)" is set', async () => { process.stdin.isTTY = false; process.argv = [ 'node', 'script.js', '-p', 'test', '--allowed-tools', 'ShellTool(wc)', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); describe('loadCliConfig interactive', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.stdin.isTTY = true; vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should be interactive if isTTY and no prompt', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); }); it('should be interactive if prompt-interactive is set', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); }); it('should not be interactive if not isTTY and no prompt', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if prompt is set', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(false); }); it('should be interactive if positional prompt words are provided with other flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); }); it('should be interactive if positional prompt words are provided with multiple flags', async () => { process.stdin.isTTY = true; process.argv = [ 'node', 'script.js', '--model', 'gemini-2.5-pro', '--yolo', 'Hello world', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); // Verify the question is preserved for one-shot execution expect(argv.prompt).toBeUndefined(); expect(argv.promptInteractive).toBe('Hello world'); }); it('should be interactive if positional prompt words are provided with extensions flag', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-e', 'none', 'hello']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello'); expect(argv.promptInteractive).toBe('hello'); expect(argv.extensions).toEqual(['none']); }); it('should handle multiple positional words correctly', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'hello world how are you']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello world how are you'); expect(argv.promptInteractive).toBe('hello world how are you'); }); it('should handle multiple positional words with flags', async () => { process.stdin.isTTY = true; process.argv = [ 'node', 'script.js', '--model', 'gemini-2.5-pro', 'write', 'a', 'function', 'to', 'sort', 'array', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('write a function to sort array'); expect(argv.promptInteractive).toBe('write a function to sort array'); expect(argv.model).toBe('gemini-2.5-pro'); }); it('should handle empty positional arguments', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); it('should handle extensions flag with positional arguments correctly', async () => { process.stdin.isTTY = true; process.argv = [ 'node', 'script.js', '-e', 'none', 'hello', 'world', 'how', 'are', 'you', ]; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello world how are you'); expect(argv.promptInteractive).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); }); it('should be interactive if no positional prompt words are provided with flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); }); }); describe('loadCliConfig approval mode', () => { const originalArgv = process.argv; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.argv = ['node', 'script.js']; // Reset argv for each test vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => { // Note: This test documents the intended behavior, but in practice the validation // prevents both flags from being used together process.argv = ['node', 'script.js', '--approval-mode', 'default']; const argv = await parseArguments(createTestMergedSettings()); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ experimental: { plan: true, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); }); it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ tools: { // @ts-expect-error: testing invalid value approvalMode: 'yolo', }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ experimental: { plan: false, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should allow plan approval mode by default when --approval-mode=plan is used', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({}); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); it('should pass planSettings.directory from settings to config', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { plan: { directory: '.custom-plans', }, }, } as unknown as MergedSettings); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); const plansDir = config.storage.getPlansDir(); expect(plansDir).toContain('.custom-plans'); }); // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); }); it('should override --approval-mode=yolo to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); describe('Persistent approvalMode setting', () => { it('should use approvalMode from settings when no CLI flags are set', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'auto_edit' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe( ServerConfig.ApprovalMode.AUTO_EDIT, ); }); it('should prioritize --approval-mode flag over settings', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'default' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe( ServerConfig.ApprovalMode.AUTO_EDIT, ); }); it('should prioritize --yolo flag over settings', async () => { process.argv = ['node', 'script.js', '--yolo']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'auto_edit' }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should respect plan mode from settings when experimental.plan is enabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'plan' }, experimental: { plan: true }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); }); it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ general: { defaultApprovalMode: 'plan' }, experimental: { plan: false }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); }); }); describe('loadCliConfig gemmaModelRouter', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should have gemmaModelRouter disabled by default', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getGemmaModelRouterEnabled()).toBe(false); }); it('should load gemmaModelRouter settings from merged settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ experimental: { gemmaModelRouter: { enabled: true, classifier: { host: 'http://custom:1234', model: 'custom-gemma', }, }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getGemmaModelRouterEnabled()).toBe(true); const gemmaSettings = config.getGemmaModelRouterSettings(); expect(gemmaSettings.classifier?.host).toBe('http://custom:1234'); expect(gemmaSettings.classifier?.model).toBe('custom-gemma'); }); it('should handle partial gemmaModelRouter settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ experimental: { gemmaModelRouter: { enabled: true, }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getGemmaModelRouterEnabled()).toBe(true); const gemmaSettings = config.getGemmaModelRouterSettings(); expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379'); expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom'); }); }); describe('loadCliConfig fileFiltering', () => { const originalArgv = process.argv; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.argv = ['node', 'script.js']; // Reset argv for each test vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); type FileFilteringSettings = NonNullable< NonNullable['fileFiltering'] >; const testCases: Array<{ property: keyof FileFilteringSettings; getter: (config: ServerConfig.Config) => boolean; value: boolean; }> = [ { property: 'enableFuzzySearch', getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: true, }, { property: 'enableFuzzySearch', getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: false, }, { property: 'respectGitIgnore', getter: (c) => c.getFileFilteringRespectGitIgnore(), value: true, }, { property: 'respectGitIgnore', getter: (c) => c.getFileFilteringRespectGitIgnore(), value: false, }, { property: 'respectGeminiIgnore', getter: (c) => c.getFileFilteringRespectGeminiIgnore(), value: true, }, { property: 'respectGeminiIgnore', getter: (c) => c.getFileFilteringRespectGeminiIgnore(), value: false, }, { property: 'enableRecursiveFileSearch', getter: (c) => c.getEnableRecursiveFileSearch(), value: true, }, { property: 'enableRecursiveFileSearch', getter: (c) => c.getEnableRecursiveFileSearch(), value: false, }, ]; it.each(testCases)( 'should pass $property from settings to config when $value', async ({ property, getter, value }) => { const settings = createTestMergedSettings({ context: { fileFiltering: { [property]: value }, }, }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); }, ); }); describe('Output format', () => { beforeEach(() => { vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.resetAllMocks(); }); it('should default to TEXT', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); it('should use the format from settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); expect(config.getOutputFormat()).toBe(OutputFormat.JSON); }); it('should prioritize the format from argv', async () => { process.argv = ['node', 'script.js', '--output-format', 'json']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); expect(config.getOutputFormat()).toBe(OutputFormat.JSON); }); it('should accept stream-json as a valid output format', async () => { process.argv = ['node', 'script.js', '--output-format', 'stream-json']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); it('should error on invalid --output-format argument', async () => { process.argv = ['node', 'script.js', '--output-format', 'invalid']; const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); const debugErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), ); expect(mockConsoleError).toHaveBeenCalled(); mockExit.mockRestore(); mockConsoleError.mockRestore(); debugErrorSpy.mockRestore(); }); }); describe('parseArguments with positional prompt', () => { const originalArgv = process.argv; beforeEach(() => { // Default to headless mode for these tests as they mostly expect one-shot behavior process.stdin.isTTY = false; Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true, writable: true, }); }); afterEach(() => { process.argv = originalArgv; }); it('should throw an error when both a positional prompt and the --prompt flag are used', async () => { process.argv = [ 'node', 'script.js', 'positional', 'prompt', '--prompt', 'test prompt', ]; const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}); const debugErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( expect.stringContaining( 'Cannot use both a positional prompt and the --prompt (-p) flag together', ), ); mockExit.mockRestore(); mockConsoleError.mockRestore(); debugErrorSpy.mockRestore(); }); it('should correctly parse a positional prompt to query field', async () => { process.argv = ['node', 'script.js', 'positional', 'prompt']; const argv = await parseArguments(createTestMergedSettings()); expect(argv.query).toBe('positional prompt'); // Since no explicit prompt flags are set and query doesn't start with @, should map to prompt (one-shot) expect(argv.prompt).toBe('positional prompt'); expect(argv.promptInteractive).toBeUndefined(); }); it('should have correct positional argument description', async () => { // Test that the positional argument has the expected description const yargsInstance = await import('./config.js'); // This test verifies that the positional 'query' argument is properly configured // with the description: "Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive." process.argv = ['node', 'script.js', 'test', 'query']; const argv = await yargsInstance.parseArguments(createTestMergedSettings()); expect(argv.query).toBe('test query'); }); it('should correctly parse a prompt from the --prompt flag', async () => { process.argv = ['node', 'script.js', '--prompt', 'test prompt']; const argv = await parseArguments(createTestMergedSettings()); expect(argv.prompt).toBe('test prompt'); }); }); describe('Telemetry configuration via environment variables', () => { beforeEach(() => { vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.resetAllMocks(); }); it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { enabled: false }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.GCP }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); vi.unstubAllEnvs(); }); it('should prioritize GEMINI_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => { vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { otlpEndpoint: 'http://settings.com' }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { otlpProtocol: 'grpc' }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { logPrompts: true }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { outfile: '/settings/telemetry.log' }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { useCollector: false }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { enabled: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getTelemetryEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ telemetry: { enabled: true } }), 'test-session', argv, ); expect(config.getTelemetryEnabled()).toBe(false); }); it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ telemetry: { logPrompts: true } }), 'test-session', argv, ); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); }); describe('PolicyEngine nonInteractive wiring', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { process.stdin.isTTY = originalIsTTY; vi.restoreAllMocks(); }); it('should set nonInteractive to true when -p flag is used', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-p', 'echo hello']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(false); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) .nonInteractive, ).toBe(true); }); it('should set nonInteractive to false in interactive mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.isInteractive()).toBe(true); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) .nonInteractive, ).toBe(false); }); }); describe('Policy Engine Integration in loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; const settings = createTestMergedSettings({ tools: { allowed: ['settings-tool'] }, }); const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ allowed: expect.arrayContaining(['cli-tool']), }), }), expect.anything(), ); }); it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { process.stdin.isTTY = false; // Non-interactive to trigger default excludes process.argv = ['node', 'script.js', '-p', 'test']; const settings = createTestMergedSettings({ tools: { exclude: ['settings-exclude'] }, }); const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); // In non-interactive mode, only ask_user is excluded by default expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), }), }), expect.anything(), ); }); it('should pass user-provided policy paths from --policy flag to createPolicyEngineConfig', async () => { process.argv = [ 'node', 'script.js', '--policy', '/path/to/policy1.toml,/path/to/policy2.toml', ]; const settings = createTestMergedSettings(); const argv = await parseArguments(settings); await loadCliConfig(settings, 'test-session', argv); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ policyPaths: [ path.normalize('/path/to/policy1.toml'), path.normalize('/path/to/policy2.toml'), ], }), expect.anything(), ); }); }); describe('loadCliConfig disableYoloMode', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should allow auto_edit mode even if yolo mode is disabled', async () => { process.argv = ['node', 'script.js', '--approval-mode=auto_edit']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ security: { disableYoloMode: true }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should throw if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ security: { disableYoloMode: true }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); }); describe('loadCliConfig secureModeEnabled', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--approval-mode=yolo']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); it('should set disableYoloMode to true when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.isYoloModeDisabled()).toBe(true); }); }); describe('loadCliConfig mcpEnabled', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); const mcpSettings = { mcp: { serverCommand: 'mcp-server', allowed: ['serverA'], excluded: ['serverB'], }, mcpServers: { serverA: { url: 'http://a' } }, }; it('should enable MCP by default', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...mcpSettings }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); it('should disable MCP when mcpEnabled is false', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: false, }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(false); expect(config.getMcpServerCommand()).toBeUndefined(); expect(config.getMcpServers()).toEqual({}); expect(config.getAllowedMcpServers()).toEqual([]); expect(config.getBlockedMcpServers()).toEqual([]); }); it('should enable MCP when mcpEnabled is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: true, }, }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); describe('extension plan settings', () => { beforeEach(() => { vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( '/mock/home/user/.gemini/tmp/test-project', ); }); it('should use plan directory from active extension when user has not specified one', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { plan: true }, }); const argv = await parseArguments(settings); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { name: 'ext-plan', isActive: true, plan: { directory: 'ext-plans-dir' }, } as unknown as GeminiCLIExtension, ]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); }); it('should NOT use plan directory from active extension when user has specified one', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { plan: true }, general: { plan: { directory: 'user-plans-dir' }, }, }); const argv = await parseArguments(settings); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { name: 'ext-plan', isActive: true, plan: { directory: 'ext-plans-dir' }, } as unknown as GeminiCLIExtension, ]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.storage.getPlansDir()).toContain('user-plans-dir'); expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); }); it('should NOT use plan directory from inactive extension', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { plan: true }, }); const argv = await parseArguments(settings); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { name: 'ext-plan', isActive: false, plan: { directory: 'ext-plans-dir-inactive' }, } as unknown as GeminiCLIExtension, ]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.storage.getPlansDir()).not.toContain( 'ext-plans-dir-inactive', ); }); it('should use default path if neither user nor extension settings provide a plan directory', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ experimental: { plan: true }, }); const argv = await parseArguments(settings); // No extensions providing plan directory vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); const config = await loadCliConfig(settings, 'test-session', argv); // Should return the default managed temp directory path expect(config.storage.getPlansDir()).toBe( path.join( '/mock', 'home', 'user', '.gemini', 'tmp', 'test-project', 'test-session', 'plans', ), ); }); }); }); describe('loadCliConfig acpMode and clientName', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { vi.unstubAllEnvs(); }); it('should set acpMode to true and detect clientName when --acp flag is used', async () => { process.argv = ['node', 'script.js', '--acp']; vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getAcpMode()).toBe(true); expect(config.getClientName()).toBe('acp-vscode'); }); it('should set acpMode to true but leave clientName undefined for generic terminals', async () => { process.argv = ['node', 'script.js', '--acp']; vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getAcpMode()).toBe(true); expect(config.getClientName()).toBeUndefined(); }); it('should set acpMode to false and clientName to undefined by default', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings(), 'test-session', argv, ); expect(config.getAcpMode()).toBe(false); expect(config.getClientName()).toBeUndefined(); }); }); ================================================ FILE: packages/cli/src/config/config.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; import { setGeminiMdFilename as setServerGeminiMdFilename, getCurrentGeminiMdFilename, ApprovalMode, DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, FileDiscoveryService, resolveTelemetrySettings, FatalConfigError, getPty, debugLogger, loadServerHierarchicalMemory, ASK_USER_TOOL_NAME, getVersion, PREVIEW_GEMINI_MODEL_AUTO, type HierarchicalMemory, coreEvents, GEMINI_MODEL_ALIAS_AUTO, getAdminErrorMessage, isHeadlessMode, Config, resolveToRealPath, applyAdminAllowlist, applyRequiredServers, getAdminBlockedMcpServersMessage, type HookDefinition, type HookEventName, type OutputFormat, detectIdeFromEnv, } from '@google/gemini-cli-core'; import { type Settings, type MergedSettings, saveModelChange, loadSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig, resolveWorkspacePolicyState, } from './policy.js'; import { ExtensionManager } from './extension-manager.js'; import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { runExitCleanup } from '../utils/cleanup.js'; export interface CliArgs { query: string | undefined; model: string | undefined; sandbox: boolean | string | undefined; debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; yolo: boolean | undefined; approvalMode: string | undefined; policy: string[] | undefined; adminPolicy: string[] | undefined; allowedMcpServerNames: string[] | undefined; allowedTools: string[] | undefined; acp?: boolean; experimentalAcp?: boolean; extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; listSessions: boolean | undefined; deleteSession: string | undefined; includeDirectories: string[] | undefined; screenReader: boolean | undefined; useWriteTodos: boolean | undefined; outputFormat: string | undefined; fakeResponses: string | undefined; recordResponses: string | undefined; startupMessages?: string[]; rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; } /** * Helper to coerce comma-separated or multiple flag values into a flat array. */ const coerceCommaSeparated = (values: string[]): string[] => { if (values.length === 1 && values[0] === '') { return ['']; } return values.flatMap((v) => v .split(',') .map((s) => s.trim()) .filter(Boolean), ); }; export async function parseArguments( settings: MergedSettings, ): Promise { const rawArgv = hideBin(process.argv); const startupMessages: string[] = []; const yargsInstance = yargs(rawArgv) .locale('en') .scriptName('gemini') .usage( 'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.', ) .option('debug', { alias: 'd', type: 'boolean', description: 'Run in debug mode (open debug console with F12)', default: false, }) .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) => yargsInstance .positional('query', { description: 'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.', }) .option('model', { alias: 'm', type: 'string', nargs: 1, description: `Model`, }) .option('prompt', { alias: 'p', type: 'string', nargs: 1, description: 'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).', }) .option('prompt-interactive', { alias: 'i', type: 'string', nargs: 1, description: 'Execute the provided prompt and continue in interactive mode', }) .option('sandbox', { alias: 's', type: 'boolean', description: 'Run in sandbox?', }) .option('yolo', { alias: 'y', type: 'boolean', description: 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', default: false, }) .option('approval-mode', { type: 'string', nargs: 1, choices: ['default', 'auto_edit', 'yolo', 'plan'], description: 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)', }) .option('policy', { type: 'array', string: true, nargs: 1, description: 'Additional policy files or directories to load (comma-separated or multiple --policy)', coerce: coerceCommaSeparated, }) .option('admin-policy', { type: 'array', string: true, nargs: 1, description: 'Additional admin policy files or directories to load (comma-separated or multiple --admin-policy)', coerce: coerceCommaSeparated, }) .option('acp', { type: 'boolean', description: 'Starts the agent in ACP mode', }) .option('experimental-acp', { type: 'boolean', description: 'Starts the agent in ACP mode (deprecated, use --acp instead)', }) .option('allowed-mcp-server-names', { type: 'array', string: true, nargs: 1, description: 'Allowed MCP server names', coerce: coerceCommaSeparated, }) .option('allowed-tools', { type: 'array', string: true, nargs: 1, description: '[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation', coerce: coerceCommaSeparated, }) .option('extensions', { alias: 'e', type: 'array', string: true, nargs: 1, description: 'A list of extensions to use. If not provided, all extensions are used.', coerce: coerceCommaSeparated, }) .option('list-extensions', { alias: 'l', type: 'boolean', description: 'List all available extensions and exit.', }) .option('resume', { alias: 'r', type: 'string', // `skipValidation` so that we can distinguish between it being passed with a value, without // one, and not being passed at all. skipValidation: true, description: 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)', coerce: (value: string): string => { // When --resume passed with a value (`gemini --resume 123`): value = "123" (string) // When --resume passed without a value (`gemini --resume`): value = "" (string) // When --resume not passed at all: this `coerce` function is not called at all, and // `yargsInstance.argv.resume` is undefined. const trimmed = value.trim(); if (trimmed === '') { return RESUME_LATEST; } return trimmed; }, }) .option('list-sessions', { type: 'boolean', description: 'List available sessions for the current project and exit.', }) .option('delete-session', { type: 'string', description: 'Delete a session by index number (use --list-sessions to see available sessions).', }) .option('include-directories', { type: 'array', string: true, nargs: 1, description: 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', coerce: coerceCommaSeparated, }) .option('screen-reader', { type: 'boolean', description: 'Enable screen reader mode for accessibility.', }) .option('output-format', { alias: 'o', type: 'string', nargs: 1, description: 'The format of the CLI output.', choices: ['text', 'json', 'stream-json'], }) .option('fake-responses', { type: 'string', description: 'Path to a file with fake model responses for testing.', hidden: true, }) .option('record-responses', { type: 'string', description: 'Path to a file to record model responses for testing.', hidden: true, }) .option('raw-output', { type: 'boolean', description: 'Disable sanitization of model output (e.g. allow ANSI escape sequences). WARNING: This can be a security risk if the model output is untrusted.', }) .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', }), ) // Register MCP subcommands .command(mcpCommand) // Ensure validation flows through .fail() for clean UX .fail((msg, err) => { if (err) throw err; throw new Error(msg); }) .check((argv) => { // The 'query' positional can be a string (for one arg) or string[] (for multiple). // This guard safely checks if any positional argument was provided. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const query = argv['query'] as string | string[] | undefined; const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; if (argv['prompt'] && hasPositionalQuery) { return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; } if (argv['prompt'] && argv['promptInteractive']) { return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together'; } if (argv['yolo'] && argv['approvalMode']) { return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; } if ( argv['outputFormat'] && !['text', 'json', 'stream-json'].includes( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv['outputFormat'] as string, ) ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } return true; }); if (settings.experimental?.extensionManagement) { yargsInstance.command(extensionsCommand); } if (settings.skills?.enabled ?? true) { yargsInstance.command(skillsCommand); } // Register hooks command if hooks are enabled if (settings.hooksConfig.enabled) { yargsInstance.command(hooksCommand); } yargsInstance .version(await getVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() .alias('h', 'help') .strict() .demandCommand(0, 0) // Allow base command to run with no subcommands .exitProcess(false); yargsInstance.wrap(yargsInstance.terminalWidth()); let result; try { result = await yargsInstance.parse(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); debugLogger.error(msg); yargsInstance.showHelp(); await runExitCleanup(); process.exit(1); } // Handle help and version flags manually since we disabled exitProcess if (result['help'] || result['version']) { await runExitCleanup(); process.exit(0); } // Normalize query args: handle both quoted "@path file" and unquoted @path file // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const queryArg = (result as { query?: string | string[] | undefined }).query; const q: string | undefined = Array.isArray(queryArg) ? queryArg.join(' ') : queryArg; // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY if (q && !result['prompt']) { if (!isHeadlessMode()) { startupMessages.push( 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', ); result['promptInteractive'] = q; } else { result['prompt'] = q; } } // Keep CliArgs.query as a string for downstream typing (result as Record)['query'] = q || undefined; (result as Record)['startupMessages'] = startupMessages; // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result as unknown as CliArgs; } export function isDebugMode(argv: CliArgs): boolean { return ( argv.debug || [process.env['DEBUG'], process.env['DEBUG_MODE']].some( (v) => v === 'true' || v === '1', ) ); } export interface LoadCliConfigOptions { cwd?: string; projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; } export async function loadCliConfig( settings: MergedSettings, sessionId: string, argv: CliArgs, options: LoadCliConfigOptions = {}, ): Promise { const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } const memoryImportFormat = settings.context?.importFormat || 'tree'; const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true; const ideMode = settings.ide?.enabled ?? false; const folderTrust = process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' || process.env['VITEST'] === 'true' ? false : (settings.security?.folderTrust?.enabled ?? false); const trustedFolder = isWorkspaceTrusted(settings, cwd, undefined, { prompt: argv.prompt, query: argv.query, })?.isTrusted ?? false; // Set the context filename in the server's memoryTool module BEFORE loading memory // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed // directly to the Config constructor in core, and have core handle setGeminiMdFilename. // However, loadHierarchicalGeminiMemory is called *before* createServerConfig. if (settings.context?.fileName) { setServerGeminiMdFilename(settings.context.fileName); } else { // Reset to default if not provided in settings. setServerGeminiMdFilename(getCurrentGeminiMdFilename()); } const fileService = new FileDiscoveryService(cwd); const memoryFileFiltering = { ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, ...settings.context?.fileFiltering, }; const fileFiltering = { ...DEFAULT_FILE_FILTERING_OPTIONS, ...settings.context?.fileFiltering, }; //changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); // When running inside VSCode with multiple workspace folders, // automatically add the other folders as include directories // so Gemini has context of all open folders, not just the cwd. const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; if (ideWorkspacePath) { const realCwd = resolveToRealPath(cwd); const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => { const trimmedPath = p.trim(); if (!trimmedPath) return false; try { return resolveToRealPath(trimmedPath) !== realCwd; } catch (e) { debugLogger.debug( `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`, ); return false; } }); includeDirectories.push(...ideFolders); } const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, workspaceDir: cwd, enabledExtensionOverrides: argv.extensions, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion eventEmitter: coreEvents as EventEmitter, clientVersion: await getVersion(), }); await extensionManager.loadExtensions(); const extensionPlanSettings = extensionManager .getExtensions() .find((ext) => ext.isActive && ext.plan?.directory)?.plan; const experimentalJitContext = settings.experimental.jitContext; let extensionRegistryURI = process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ?? (trustedFolder ? settings.experimental?.extensionRegistryURI : undefined); if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { extensionRegistryURI = resolveToRealPath( path.resolve(cwd, resolvePath(extensionRegistryURI)), ); } let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; let filePaths: string[] = []; if (!experimentalJitContext) { // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const result = await loadServerHierarchicalMemory( cwd, settings.context?.loadMemoryFromIncludeDirectories || false ? includeDirectories : [], fileService, extensionManager, trustedFolder, memoryImportFormat, memoryFileFiltering, settings.context?.discoveryMaxDirs, ); memoryContent = result.memoryContent; fileCount = result.fileCount; filePaths = result.filePaths; } const question = argv.promptInteractive || argv.prompt || ''; // Determine approval mode with backward compatibility let approvalMode: ApprovalMode; const rawApprovalMode = argv.approvalMode || (argv.yolo ? 'yolo' : undefined) || ((settings.general?.defaultApprovalMode as string) !== 'yolo' ? settings.general?.defaultApprovalMode : undefined); if (rawApprovalMode) { switch (rawApprovalMode) { case 'yolo': approvalMode = ApprovalMode.YOLO; break; case 'auto_edit': approvalMode = ApprovalMode.AUTO_EDIT; break; case 'plan': if (!(settings.experimental?.plan ?? false)) { debugLogger.warn( 'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".', ); approvalMode = ApprovalMode.DEFAULT; } else { approvalMode = ApprovalMode.PLAN; } break; case 'default': approvalMode = ApprovalMode.DEFAULT; break; default: throw new Error( `Invalid approval mode: ${rawApprovalMode}. Valid values are: yolo, auto_edit, plan, default`, ); } } else { approvalMode = ApprovalMode.DEFAULT; } // Override approval mode if disableYoloMode is set. if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) { if (approvalMode === ApprovalMode.YOLO) { if (settings.admin?.secureModeEnabled) { debugLogger.error( 'YOLO mode is disabled by "secureModeEnabled" setting.', ); } else { debugLogger.error( 'YOLO mode is disabled by the "disableYolo" setting.', ); } throw new FatalConfigError( getAdminErrorMessage('YOLO mode', undefined /* config */), ); } } else if (approvalMode === ApprovalMode.YOLO) { debugLogger.warn( 'YOLO mode is enabled. All tool calls will be automatically approved.', ); } // Force approval mode to default if the folder is not trusted. if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) { debugLogger.warn( `Approval mode overridden to "default" because the current folder is not trusted.`, ); approvalMode = ApprovalMode.DEFAULT; } let telemetrySettings; try { telemetrySettings = await resolveTelemetrySettings({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion env: process.env as unknown as Record, settings: settings.telemetry, }); } catch (err) { if (err instanceof FatalConfigError) { throw new FatalConfigError( `Invalid telemetry configuration: ${err.message}.`, ); } throw err; } // -p/--prompt forces non-interactive (headless) mode // -i/--prompt-interactive forces interactive mode with an initial prompt const interactive = !!argv.promptInteractive || !!argv.acp || !!argv.experimentalAcp || (!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) && !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive) { // The Policy Engine natively handles headless safety by translating ASK_USER // decisions to DENY. However, we explicitly block ask_user here to guarantee // it can never be allowed via a high-priority policy rule when no human is present. extraExcludes.push(ASK_USER_TOOL_NAME); } const excludeTools = mergeExcludeTools(settings, extraExcludes); // Create a settings object that includes CLI overrides for policy generation const effectiveSettings: Settings = { ...settings, tools: { ...settings.tools, allowed: allowedTools, exclude: excludeTools, }, mcp: { ...settings.mcp, allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed, }, policyPaths: (argv.policy ?? settings.policyPaths)?.map((p) => resolvePath(p), ), adminPolicyPaths: (argv.adminPolicy ?? settings.adminPolicyPaths)?.map( (p) => resolvePath(p), ), }; const { workspacePoliciesDir, policyUpdateConfirmationRequest } = await resolveWorkspacePolicyState({ cwd, trustedFolder, interactive, }); const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, approvalMode, workspacePoliciesDir, ); policyEngineConfig.nonInteractive = !interactive; const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; const resolvedModel = specifiedModel === GEMINI_MODEL_ALIAS_AUTO ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); if (sandboxConfig) { const existingPaths = sandboxConfig.allowedPaths || []; if (settings.tools.sandboxAllowedPaths?.length) { sandboxConfig.allowedPaths = [ ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), ]; } if (settings.tools.sandboxNetworkAccess !== undefined) { sandboxConfig.networkAccess = sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; } } const screenReader = argv.screenReader !== undefined ? argv.screenReader : (settings.ui?.accessibility?.screenReader ?? false); const ptyInfo = await getPty(); const mcpEnabled = settings.admin?.mcp?.enabled ?? true; const extensionsEnabled = settings.admin?.extensions?.enabled ?? true; const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true; // Create MCP enablement manager and callbacks const mcpEnablementManager = McpServerEnablementManager.getInstance(); const mcpEnablementCallbacks = mcpEnabled ? mcpEnablementManager.getEnablementCallbacks() : undefined; const adminAllowlist = settings.admin?.mcp?.config; let mcpServerCommand = mcpEnabled ? settings.mcp?.serverCommand : undefined; let mcpServers = mcpEnabled ? settings.mcpServers : {}; if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) { const result = applyAdminAllowlist(mcpServers, adminAllowlist); mcpServers = result.mcpServers; mcpServerCommand = undefined; if (result.blockedServerNames && result.blockedServerNames.length > 0) { const message = getAdminBlockedMcpServersMessage( result.blockedServerNames, undefined, ); coreEvents.emitConsoleLog('warn', message); } } // Apply admin-required MCP servers (injected regardless of allowlist) if (mcpEnabled) { const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { const requiredResult = applyRequiredServers( mcpServers ?? {}, requiredMcpConfig, ); mcpServers = requiredResult.mcpServers; if (requiredResult.requiredServerNames.length > 0) { coreEvents.emitConsoleLog( 'info', `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, ); } } } const isAcpMode = !!argv.acp || !!argv.experimentalAcp; let clientName: string | undefined = undefined; if (isAcpMode) { const ide = detectIdeFromEnv(); if ( ide && (ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode') ) { clientName = `acp-${ide.name}`; } } return new Config({ acpMode: isAcpMode, clientName, sessionId, clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, toolSandboxing: settings.security?.toolSandboxing ?? false, targetDir: cwd, includeDirectoryTree, includeDirectories, loadMemoryFromIncludeDirectories: settings.context?.loadMemoryFromIncludeDirectories || false, discoveryMaxDirs: settings.context?.discoveryMaxDirs, importFormat: settings.context?.importFormat, debugMode, question, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, policyEngineConfig, policyUpdateConfirmationRequest, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, mcpServerCommand, mcpServers, mcpEnablementCallbacks, mcpEnabled, extensionsEnabled, agents: settings.agents, adminSkillsEnabled, allowedMcpServers: mcpEnabled ? (argv.allowedMcpServerNames ?? settings.mcp?.allowed) : undefined, blockedMcpServers: mcpEnabled ? argv.allowedMcpServerNames ? undefined : settings.mcp?.excluded : undefined, blockedEnvironmentVariables: settings.security?.environmentVariableRedaction?.blocked, enableEnvironmentVariableRedaction: settings.security?.environmentVariableRedaction?.enabled, userMemory: memoryContent, geminiMdFileCount: fileCount, geminiMdFilePaths: filePaths, approvalMode, disableYoloMode: settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, disableAlwaysAllow: settings.security?.disableAlwaysAllow || settings.admin?.secureModeEnabled, showMemoryUsage: settings.ui?.showMemoryUsage || false, accessibility: { ...settings.ui?.accessibility, screenReader, }, telemetry: telemetrySettings, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled, fileFiltering, checkpointing: settings.general?.checkpointing?.enabled, proxy: process.env['HTTPS_PROXY'] || process.env['https_proxy'] || process.env['HTTP_PROXY'] || process.env['http_proxy'], cwd, fileDiscoveryService: fileService, bugCommand: settings.advanced?.bugCommand, model: resolvedModel, maxSessionTurns: settings.model?.maxSessionTurns, listExtensions: argv.listExtensions || false, listSessions: argv.listSessions || false, deleteSession: argv.deleteSession, enabledExtensions: argv.extensions, extensionLoader: extensionManager, extensionRegistryURI, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, tracker: settings.experimental?.taskTracker, directWebFetch: settings.experimental?.directWebFetch, planSettings: settings.general?.plan?.directory ? settings.general.plan : (extensionPlanSettings ?? settings.general?.plan), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, experimentalMemoryManager: settings.experimental?.memoryManager, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, disableLoopDetection: settings.model?.disableLoopDetection, compressionThreshold: settings.model?.compressionThreshold, folderTrust, interactive, trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, enableShellOutputEfficiency: settings.tools?.shell?.enableShellOutputEfficiency ?? true, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, eventEmitter: coreEvents, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, gemmaModelRouter: settings.experimental?.gemmaModelRouter, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, billing: settings.billing, maxAttempts: settings.general?.maxAttempts, ptyInfo: ptyInfo?.name, disableLLMCorrection: settings.tools?.disableLLMCorrection, rawOutput: argv.rawOutput, acceptRawOutputRisk: argv.acceptRawOutputRisk, dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust enableHooks: settings.hooksConfig.enabled, enableHooksUI: settings.hooksConfig.enabled, hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { disabledSkills: refreshedSettings.merged.skills.disabled, agents: refreshedSettings.merged.agents, }; }, enableConseca: settings.security?.enableConseca, }); } function mergeExcludeTools( settings: MergedSettings, extraExcludes: string[] = [], ): string[] { const allExcludeTools = new Set([ ...(settings.tools.exclude || []), ...extraExcludes, ]); return Array.from(allExcludeTools); } ================================================ FILE: packages/cli/src/config/extension-manager-agents.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger } from '@google/gemini-cli-core'; import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, }; }); // Mock @google/gemini-cli-core vi.mock('@google/gemini-cli-core', async (importOriginal) => { const core = await importOriginal(); return { ...core, homedir: mockHomedir, loadAgentsFromDirectory: core.loadAgentsFromDirectory, loadSkillsFromDir: core.loadSkillsFromDir, }; }); describe('ExtensionManager agents loading', () => { let extensionManager: ExtensionManager; let tempDir: string; let extensionsDir: string; beforeEach(() => { vi.clearAllMocks(); vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-')); mockHomedir.mockReturnValue(tempDir); // Create the extensions directory that ExtensionManager expects extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ settings: createTestMergedSettings({ telemetry: { enabled: false }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, }); }); afterEach(() => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // ignore } }); it('should load agents from an extension', async () => { const sourceDir = path.join(tempDir, 'source-ext-good'); createExtension({ extensionsDir: sourceDir, name: 'good-agents-ext', version: '1.0.0', installMetadata: { type: 'local', source: path.join(sourceDir, 'good-agents-ext'), }, }); const extensionPath = path.join(sourceDir, 'good-agents-ext'); const agentsDir = path.join(extensionPath, 'agents'); fs.mkdirSync(agentsDir, { recursive: true }); fs.writeFileSync( path.join(agentsDir, 'test-agent.md'), '---\nname: test-agent\nkind: local\ndescription: test desc\n---\nbody', ); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.name).toBe('good-agents-ext'); expect(extension.agents).toBeDefined(); expect(extension.agents).toHaveLength(1); expect(extension.agents![0].name).toBe('test-agent'); expect(debugLogger.warn).not.toHaveBeenCalled(); }); it('should log errors but continue if an agent fails to load', async () => { const sourceDir = path.join(tempDir, 'source-ext-bad'); createExtension({ extensionsDir: sourceDir, name: 'bad-agents-ext', version: '1.0.0', installMetadata: { type: 'local', source: path.join(sourceDir, 'bad-agents-ext'), }, }); const extensionPath = path.join(sourceDir, 'bad-agents-ext'); const agentsDir = path.join(extensionPath, 'agents'); fs.mkdirSync(agentsDir, { recursive: true }); // Invalid agent (missing description) fs.writeFileSync( path.join(agentsDir, 'bad-agent.md'), '---\nname: bad-agent\nkind: local\n---\nbody', ); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.name).toBe('bad-agents-ext'); expect(extension.agents).toEqual([]); expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Error loading agent from bad-agents-ext'), ); }); }); ================================================ FILE: packages/cli/src/config/extension-manager-hydration.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger, coreEvents, type CommandHookConfig, } from '@google/gemini-cli-core'; import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, }; }); // Mock @google/gemini-cli-core vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, // Use actual implementations for loading skills and agents to test hydration loadAgentsFromDirectory: actual.loadAgentsFromDirectory, loadSkillsFromDir: actual.loadSkillsFromDir, }; }); describe('ExtensionManager hydration', () => { let extensionManager: ExtensionManager; let tempDir: string; let extensionsDir: string; beforeEach(() => { vi.clearAllMocks(); vi.spyOn(coreEvents, 'emitFeedback'); vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); mockHomedir.mockReturnValue(tempDir); // Create the extensions directory that ExtensionManager expects extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, }); }); afterEach(() => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // ignore } }); it('should hydrate skill body with extension settings', async () => { const sourceDir = path.join(tempDir, 'source-ext-skill'); const extensionName = 'skill-hydration-ext'; createExtension({ extensionsDir: sourceDir, name: extensionName, version: '1.0.0', settings: [ { name: 'API Key', description: 'API Key', envVar: 'MY_API_KEY', }, ], installMetadata: { type: 'local', source: path.join(sourceDir, extensionName), }, }); const extensionPath = path.join(sourceDir, extensionName); // Create skill with variable const skillsDir = path.join(extensionPath, 'skills'); const skillSubdir = path.join(skillsDir, 'my-skill'); fs.mkdirSync(skillSubdir, { recursive: true }); fs.writeFileSync( path.join(skillSubdir, 'SKILL.md'), `--- name: my-skill description: test --- Use key: \${MY_API_KEY} `, ); await extensionManager.loadExtensions(); extensionManager.setRequestSetting(async (setting) => { if (setting.envVar === 'MY_API_KEY') return 'secret-123'; return ''; }); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.skills).toHaveLength(1); expect(extension.skills![0].body).toContain('Use key: secret-123'); }); it('should hydrate agent system prompt with extension settings', async () => { const sourceDir = path.join(tempDir, 'source-ext-agent'); const extensionName = 'agent-hydration-ext'; createExtension({ extensionsDir: sourceDir, name: extensionName, version: '1.0.0', settings: [ { name: 'Model Name', description: 'Model', envVar: 'MODEL_NAME', }, ], installMetadata: { type: 'local', source: path.join(sourceDir, extensionName), }, }); const extensionPath = path.join(sourceDir, extensionName); // Create agent with variable const agentsDir = path.join(extensionPath, 'agents'); fs.mkdirSync(agentsDir, { recursive: true }); fs.writeFileSync( path.join(agentsDir, 'my-agent.md'), `--- name: my-agent description: test --- System using model: \${MODEL_NAME} `, ); await extensionManager.loadExtensions(); extensionManager.setRequestSetting(async (setting) => { if (setting.envVar === 'MODEL_NAME') return 'gemini-pro'; return ''; }); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.agents).toHaveLength(1); const agent = extension.agents![0]; if (agent.kind === 'local') { expect(agent.promptConfig.systemPrompt).toContain( 'System using model: gemini-pro', ); } else { throw new Error('Expected local agent'); } }); it('should hydrate hooks with extension settings', async () => { const sourceDir = path.join(tempDir, 'source-ext-hooks'); const extensionName = 'hooks-hydration-ext'; createExtension({ extensionsDir: sourceDir, name: extensionName, version: '1.0.0', settings: [ { name: 'Hook Command', description: 'Cmd', envVar: 'HOOK_CMD', }, ], installMetadata: { type: 'local', source: path.join(sourceDir, extensionName), }, }); const extensionPath = path.join(sourceDir, extensionName); const hooksDir = path.join(extensionPath, 'hooks'); fs.mkdirSync(hooksDir, { recursive: true }); fs.writeFileSync( path.join(hooksDir, 'hooks.json'), JSON.stringify({ hooks: { BeforeTool: [ { hooks: [ { type: 'command', command: 'echo $HOOK_CMD', }, ], }, ], }, }), ); // Enable hooks in settings extensionManager = new ExtensionManager({ settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, hooksConfig: { enabled: true }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, }); await extensionManager.loadExtensions(); extensionManager.setRequestSetting(async (setting) => { if (setting.envVar === 'HOOK_CMD') return 'hello-world'; return ''; }); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.hooks).toBeDefined(); expect(extension.hooks?.BeforeTool).toHaveLength(1); expect( (extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[ 'HOOK_CMD' ], ).toBe('hello-world'); }); it('should pick up new settings after restartExtension', async () => { const sourceDir = path.join(tempDir, 'source-ext-restart'); const extensionName = 'restart-hydration-ext'; createExtension({ extensionsDir: sourceDir, name: extensionName, version: '1.0.0', settings: [ { name: 'Value', description: 'Val', envVar: 'MY_VALUE', }, ], installMetadata: { type: 'local', source: path.join(sourceDir, extensionName), }, }); const extensionPath = path.join(sourceDir, extensionName); const skillsDir = path.join(extensionPath, 'skills'); const skillSubdir = path.join(skillsDir, 'my-skill'); fs.mkdirSync(skillSubdir, { recursive: true }); fs.writeFileSync( path.join(skillSubdir, 'SKILL.md'), '---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}', ); await extensionManager.loadExtensions(); // Initial setting extensionManager.setRequestSetting(async () => 'first'); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.skills![0].body).toContain('Value is: first'); const { updateSetting, ExtensionSettingScope } = await import( './extensions/extensionSettings.js' ); const extensionConfig = await extensionManager.loadExtensionConfig(extensionPath); const mockRequestSetting = vi.fn().mockResolvedValue('second'); await updateSetting( extensionConfig, extension.id, 'MY_VALUE', mockRequestSetting, ExtensionSettingScope.USER, process.cwd(), ); await extensionManager.restartExtension(extension); const reloadedExtension = extensionManager .getExtensions() .find((e) => e.name === extensionName)!; expect(reloadedExtension.skills![0].body).toContain('Value is: second'); }); }); ================================================ FILE: packages/cli/src/config/extension-manager-permissions.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { copyExtension } from './extension-manager.js'; describe('copyExtension permissions', () => { let tempDir: string; let sourceDir: string; let destDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); sourceDir = path.join(tempDir, 'source'); destDir = path.join(tempDir, 'dest'); fs.mkdirSync(sourceDir); }); afterEach(() => { // Ensure we can delete the temp directory by making everything writable again const makeWritableSync = (p: string) => { try { const stats = fs.lstatSync(p); fs.chmodSync(p, stats.mode | 0o700); if (stats.isDirectory()) { fs.readdirSync(p).forEach((child) => makeWritableSync(path.join(p, child)), ); } } catch (_e) { // Ignore errors during cleanup } }; if (fs.existsSync(tempDir)) { makeWritableSync(tempDir); fs.rmSync(tempDir, { recursive: true, force: true }); } }); it('should make destination writable even if source is read-only', async () => { const fileName = 'test.txt'; const filePath = path.join(sourceDir, fileName); fs.writeFileSync(filePath, 'hello'); // Make source read-only: 0o555 for directory, 0o444 for file fs.chmodSync(filePath, 0o444); fs.chmodSync(sourceDir, 0o555); // Verify source is read-only expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); // Perform copy await copyExtension(sourceDir, destDir); // Verify destination is writable const destFilePath = path.join(destDir, fileName); const destFileStats = fs.statSync(destFilePath); const destDirStats = fs.statSync(destDir); // Check that owner write bits are set (0o200) expect(destFileStats.mode & 0o200).toBe(0o200); expect(destDirStats.mode & 0o200).toBe(0o200); // Verify we can actually write to the destination file fs.writeFileSync(destFilePath, 'writable'); expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); // Verify we can delete the destination (which requires write bit on destDir) fs.rmSync(destFilePath); expect(fs.existsSync(destFilePath)).toBe(false); }); it('should handle nested directories with restrictive permissions', async () => { const subDir = path.join(sourceDir, 'subdir'); fs.mkdirSync(subDir); const fileName = 'nested.txt'; const filePath = path.join(subDir, fileName); fs.writeFileSync(filePath, 'nested content'); // Make nested structure read-only fs.chmodSync(filePath, 0o444); fs.chmodSync(subDir, 0o555); fs.chmodSync(sourceDir, 0o555); // Perform copy await copyExtension(sourceDir, destDir); // Verify nested destination is writable const destSubDir = path.join(destDir, 'subdir'); const destFilePath = path.join(destSubDir, fileName); expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); // Verify we can delete the whole destination tree await fs.promises.rm(destDir, { recursive: true, force: true }); expect(fs.existsSync(destDir)).toBe(false); }); it('should not follow symlinks or modify symlink targets', async () => { const symlinkTarget = path.join(tempDir, 'external-target'); fs.writeFileSync(symlinkTarget, 'external content'); // Target is read-only fs.chmodSync(symlinkTarget, 0o444); const symlinkPath = path.join(sourceDir, 'symlink-file'); fs.symlinkSync(symlinkTarget, symlinkPath); // Perform copy await copyExtension(sourceDir, destDir); const destSymlinkPath = path.join(destDir, 'symlink-file'); const destSymlinkStats = fs.lstatSync(destSymlinkPath); // Verify it is still a symlink in the destination expect(destSymlinkStats.isSymbolicLink()).toBe(true); // Verify the target (external to the extension) was NOT modified const targetStats = fs.statSync(symlinkTarget); // Owner write bit should still NOT be set (0o200) expect(targetStats.mode & 0o200).toBe(0o000); // Clean up fs.chmodSync(symlinkTarget, 0o644); }); }); ================================================ FILE: packages/cli/src/config/extension-manager-scope.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { createTestMergedSettings } from './settings.js'; import { loadAgentsFromDirectory, loadSkillsFromDir, } from '@google/gemini-cli-core'; let currentTempHome = ''; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: () => currentTempHome, debugLogger: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), }, loadAgentsFromDirectory: vi.fn().mockImplementation(async () => ({ agents: [], errors: [], })), loadSkillsFromDir: vi.fn().mockImplementation(async () => []), }; }); describe('ExtensionManager Settings Scope', () => { const extensionName = 'test-extension'; let tempWorkspace: string; let extensionsDir: string; let extensionDir: string; beforeEach(async () => { vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ agents: [], errors: [], }); vi.mocked(loadSkillsFromDir).mockResolvedValue([]); currentTempHome = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); tempWorkspace = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), ); extensionsDir = path.join(currentTempHome, '.gemini', 'extensions'); extensionDir = path.join(extensionsDir, extensionName); fs.mkdirSync(extensionDir, { recursive: true }); // Create gemini-extension.json const extensionConfig = { name: extensionName, version: '1.0.0', settings: [ { name: 'Test Setting', envVar: 'TEST_SETTING', description: 'A test setting', }, ], }; fs.writeFileSync( path.join(extensionDir, 'gemini-extension.json'), JSON.stringify(extensionConfig), ); // Create install metadata const installMetadata = { source: extensionDir, type: 'local', }; fs.writeFileSync( path.join(extensionDir, 'install-metadata.json'), JSON.stringify(installMetadata), ); }); afterEach(() => { // Clean up files if needed, or rely on temp dir cleanup vi.clearAllMocks(); }); it('should prioritize workspace settings over user settings and report correct scope', async () => { // 1. Set User Setting const userSettingsPath = path.join(extensionDir, '.env'); fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); // 2. Set Workspace Setting const workspaceSettingsPath = path.join(tempWorkspace, '.env'); fs.writeFileSync(workspaceSettingsPath, 'TEST_SETTING=workspace-value'); const extensionManager = new ExtensionManager({ workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, security: { folderTrust: { enabled: false } }, }), }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === extensionName); expect(extension).toBeDefined(); // Verify resolved settings const setting = extension?.resolvedSettings?.find( (s) => s.envVar === 'TEST_SETTING', ); expect(setting).toBeDefined(); expect(setting?.value).toBe('workspace-value'); expect(setting?.scope).toBe('workspace'); expect(setting?.source).toBe(workspaceSettingsPath); // Verify output string contains (Workspace - ) const output = extensionManager.toOutputString(extension!); expect(output).toContain( `Test Setting: workspace-value (Workspace - ${workspaceSettingsPath})`, ); }); it('should fallback to user settings if workspace setting is missing', async () => { // 1. Set User Setting const userSettingsPath = path.join(extensionDir, '.env'); fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); // 2. No Workspace Setting const extensionManager = new ExtensionManager({ workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, security: { folderTrust: { enabled: false } }, }), }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === extensionName); expect(extension).toBeDefined(); // Verify resolved settings const setting = extension?.resolvedSettings?.find( (s) => s.envVar === 'TEST_SETTING', ); expect(setting).toBeDefined(); expect(setting?.value).toBe('user-value'); expect(setting?.scope).toBe('user'); expect(setting?.source?.endsWith(path.join(extensionName, '.env'))).toBe( true, ); // Verify output string contains (User - ) const output = extensionManager.toOutputString(extension!); expect(output).toContain( `Test Setting: user-value (User - ${userSettingsPath})`, ); }); it('should report unset if neither is present', async () => { // No settings files const extensionManager = new ExtensionManager({ workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, security: { folderTrust: { enabled: false } }, }), }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === extensionName); expect(extension).toBeDefined(); // Verify resolved settings const setting = extension?.resolvedSettings?.find( (s) => s.envVar === 'TEST_SETTING', ); expect(setting).toBeDefined(); expect(setting?.value).toBeUndefined(); expect(setting?.scope).toBeUndefined(); // Verify output string does not contain scope const output = extensionManager.toOutputString(extension!); expect(output).toContain('Test Setting: [not set]'); expect(output).not.toContain('Test Setting: [not set] (User)'); expect(output).not.toContain('Test Setting: [not set] (Workspace)'); }); }); ================================================ FILE: packages/cli/src/config/extension-manager-skills.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger, coreEvents } from '@google/gemini-cli-core'; import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); const mockIntegrityManager = vi.hoisted(() => ({ verify: vi.fn().mockResolvedValue('verified'), store: vi.fn().mockResolvedValue(undefined), })); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, }; }); // Mock @google/gemini-cli-core vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, ExtensionIntegrityManager: vi .fn() .mockImplementation(() => mockIntegrityManager), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), loadSkillsFromDir: ( await importOriginal() ).loadSkillsFromDir, }; }); describe('ExtensionManager skills validation', () => { let extensionManager: ExtensionManager; let tempDir: string; let extensionsDir: string; beforeEach(() => { vi.clearAllMocks(); vi.spyOn(coreEvents, 'emitFeedback'); vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); mockHomedir.mockReturnValue(tempDir); // Create the extensions directory that ExtensionManager expects extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ settings: createTestMergedSettings({ telemetry: { enabled: false }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, integrityManager: mockIntegrityManager, }); }); afterEach(() => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // ignore } }); it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => { // Create a source extension const sourceDir = path.join(tempDir, 'source-ext'); createExtension({ extensionsDir: sourceDir, // createExtension appends name name: 'skills-ext', version: '1.0.0', installMetadata: { type: 'local', source: path.join(sourceDir, 'skills-ext'), }, }); const extensionPath = path.join(sourceDir, 'skills-ext'); // Add invalid skills content const skillsDir = path.join(extensionPath, 'skills'); fs.mkdirSync(skillsDir); fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); }); it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => { // 1. Create a source extension const sourceDir = path.join(tempDir, 'source-ext-load'); createExtension({ extensionsDir: sourceDir, name: 'skills-ext-load', version: '1.0.0', }); const sourceExtPath = path.join(sourceDir, 'skills-ext-load'); // Add invalid skills content const skillsDir = path.join(sourceExtPath, 'skills'); fs.mkdirSync(skillsDir); fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); // 2. Install it to ensure correct disk state await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ type: 'local', source: sourceExtPath, }); // Clear the spy vi.mocked(debugLogger.debug).mockClear(); // 3. Create a fresh ExtensionManager to force loading from disk const newExtensionManager = new ExtensionManager({ settings: createTestMergedSettings({ telemetry: { enabled: false }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, integrityManager: mockIntegrityManager, }); // 4. Load extensions await newExtensionManager.loadExtensions(); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); }); it('should succeed if skills are correctly loaded', async () => { const sourceDir = path.join(tempDir, 'source-ext-good'); createExtension({ extensionsDir: sourceDir, name: 'good-skills-ext', version: '1.0.0', installMetadata: { type: 'local', source: path.join(sourceDir, 'good-skills-ext'), }, }); const extensionPath = path.join(sourceDir, 'good-skills-ext'); const skillsDir = path.join(extensionPath, 'skills'); const skillSubdir = path.join(skillsDir, 'test-skill'); fs.mkdirSync(skillSubdir, { recursive: true }); fs.writeFileSync( path.join(skillSubdir, 'SKILL.md'), '---\nname: test-skill\ndescription: test desc\n---\nbody', ); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension({ type: 'local', source: extensionPath, }); expect(extension.name).toBe('good-skills-ext'); expect(debugLogger.debug).not.toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); }); }); ================================================ FILE: packages/cli/src/config/extension-manager-themes.spec.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { beforeAll, afterAll, beforeEach, describe, expect, it, vi, afterEach, } from 'vitest'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { GEMINI_DIR, type Config, tmpdir, NoopSandboxManager, } from '@google/gemini-cli-core'; import { createTestMergedSettings, SettingScope } from './settings.js'; describe('ExtensionManager theme loading', () => { let extensionManager: ExtensionManager; let userExtensionsDir: string; let tempHomeDir: string; beforeAll(async () => { tempHomeDir = await fs.promises.mkdtemp( path.join(tmpdir(), 'gemini-cli-test-'), ); }); afterAll(async () => { if (tempHomeDir) { await fs.promises.rm(tempHomeDir, { recursive: true, force: true }); } }); beforeEach(() => { process.env['GEMINI_CLI_HOME'] = tempHomeDir; userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); // Ensure userExtensionsDir is clean for each test fs.rmSync(userExtensionsDir, { recursive: true, force: true }); fs.mkdirSync(userExtensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ settings: createTestMergedSettings({ experimental: { extensionConfig: true }, security: { blockGitExtensions: false }, admin: { extensions: { enabled: true }, mcp: { enabled: true } }, }), requestConsent: async () => true, requestSetting: async () => '', workspaceDir: tempHomeDir, enabledExtensionOverrides: [], }); vi.clearAllMocks(); themeManager.clearExtensionThemes(); themeManager.loadCustomThemes({}); themeManager.setActiveTheme(DEFAULT_THEME.name); }); afterEach(() => { delete process.env['GEMINI_CLI_HOME']; }); it('should register themes from an extension when started', async () => { const registerSpy = vi.spyOn(themeManager, 'registerExtensionThemes'); createExtension({ extensionsDir: userExtensionsDir, name: 'my-theme-extension', themes: [ { name: 'My-Awesome-Theme', type: 'custom', text: { primary: '#FF00FF', }, }, ], }); await extensionManager.loadExtensions(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { getEnableExtensionReloading: () => false, getMcpClientManager: () => ({ startExtension: vi.fn().mockResolvedValue(undefined), }), getGeminiClient: () => ({ isInitialized: () => false, updateSystemInstruction: vi.fn(), setTools: vi.fn(), }), getHookSystem: () => undefined, getWorkingDir: () => tempHomeDir, shouldLoadMemoryFromIncludeDirectories: () => false, getDebugMode: () => false, getFileExclusions: () => ({ isIgnored: () => false, }), getGeminiMdFilePaths: () => [], getMcpServers: () => ({}), getAllowedMcpServers: () => [], getSanitizationConfig: () => ({ allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }), getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24, showColor: false, pager: 'cat', sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }, }), getToolRegistry: () => ({ getTools: () => [], }), getProxy: () => undefined, getFileService: () => ({ findFiles: async () => [], }), getExtensionLoader: () => ({ getExtensions: () => [], }), isTrustedFolder: () => true, getImportFormat: () => 'tree', reloadSkills: vi.fn(), } as unknown as Config; await extensionManager.start(mockConfig); expect(registerSpy).toHaveBeenCalledWith('my-theme-extension', [ { name: 'My-Awesome-Theme', type: 'custom', text: { primary: '#FF00FF', }, }, ]); }); it('should revert to default theme when extension is stopped', async () => { const extensionName = 'my-theme-extension'; const themeName = 'My-Awesome-Theme'; const namespacedThemeName = `${themeName} (${extensionName})`; createExtension({ extensionsDir: userExtensionsDir, name: extensionName, themes: [ { name: themeName, type: 'custom', text: { primary: '#FF00FF', }, }, ], }); await extensionManager.loadExtensions(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { getWorkingDir: () => tempHomeDir, shouldLoadMemoryFromIncludeDirectories: () => false, getWorkspaceContext: () => ({ getDirectories: () => [], }), getDebugMode: () => false, getFileService: () => ({ findFiles: async () => [], }), getExtensionLoader: () => ({ getExtensions: () => [], }), isTrustedFolder: () => true, getImportFormat: () => 'tree', getFileFilteringOptions: () => ({ respectGitIgnore: true, respectGeminiIgnore: true, }), getDiscoveryMaxDirs: () => 200, getMcpClientManager: () => ({ getMcpInstructions: () => '', startExtension: vi.fn().mockResolvedValue(undefined), stopExtension: vi.fn().mockResolvedValue(undefined), }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), setGeminiMdFilePaths: vi.fn(), getEnableExtensionReloading: () => true, getGeminiClient: () => ({ isInitialized: () => false, updateSystemInstruction: vi.fn(), setTools: vi.fn(), }), getHookSystem: () => undefined, getProxy: () => undefined, getAgentRegistry: () => ({ reload: vi.fn().mockResolvedValue(undefined), }), reloadSkills: vi.fn(), } as unknown as Config; await extensionManager.start(mockConfig); // Set the active theme to the one from the extension themeManager.setActiveTheme(namespacedThemeName); expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); // Stop the extension await extensionManager.disableExtension(extensionName, SettingScope.User); // Check that the active theme has reverted to the default expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); }); }); ================================================ FILE: packages/cli/src/config/extension-manager.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { ExtensionManager } from './extension-manager.js'; import { createTestMergedSettings, type MergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; import { themeManager } from '../ui/themes/theme-manager.js'; import { TrustLevel, loadTrustedFolders, isWorkspaceTrusted, } from './trustedFolders.js'; import { getRealPath, type CustomTheme, IntegrityDataStatus, } from '@google/gemini-cli-core'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); const mockIntegrityManager = vi.hoisted(() => ({ verify: vi.fn().mockResolvedValue('verified'), store: vi.fn().mockResolvedValue(undefined), })); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, homedir: mockHomedir, }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir, ExtensionIntegrityManager: vi .fn() .mockImplementation(() => mockIntegrityManager), }; }); const testTheme: CustomTheme = { type: 'custom', name: 'MyTheme', background: { primary: '#282828', diff: { added: '#2b3312', removed: '#341212' }, }, text: { primary: '#ebdbb2', secondary: '#a89984', link: '#83a598', accent: '#d3869b', }, status: { success: '#b8bb26', warning: '#fabd2f', error: '#fb4934', }, }; describe('ExtensionManager', () => { let tempHomeDir: string; let tempWorkspaceDir: string; let userExtensionsDir: string; let extensionManager: ExtensionManager; beforeEach(() => { vi.clearAllMocks(); tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); tempWorkspaceDir = fs.mkdtempSync( path.join(tempHomeDir, 'gemini-cli-test-workspace-'), ); mockHomedir.mockReturnValue(tempHomeDir); userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(userExtensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ settings: createTestMergedSettings(), workspaceDir: tempWorkspaceDir, requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, integrityManager: mockIntegrityManager, }); }); afterEach(() => { themeManager.clearExtensionThemes(); try { fs.rmSync(tempHomeDir, { recursive: true, force: true }); } catch (_e) { // Ignore } }); describe('loadExtensions parallel loading', () => { it('should prevent concurrent loading and return the same promise', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); createExtension({ extensionsDir: userExtensionsDir, name: 'ext2', version: '1.0.0', }); // Call loadExtensions twice concurrently const promise1 = extensionManager.loadExtensions(); const promise2 = extensionManager.loadExtensions(); // They should resolve to the exact same array const [extensions1, extensions2] = await Promise.all([ promise1, promise2, ]); expect(extensions1).toBe(extensions2); expect(extensions1).toHaveLength(2); const names = extensions1.map((ext) => ext.name).sort(); expect(names).toEqual(['ext1', 'ext2']); }); it('should throw an error if loadExtensions is called after it has already resolved', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); await extensionManager.loadExtensions(); await expect(extensionManager.loadExtensions()).rejects.toThrow( 'Extensions already loaded, only load extensions once.', ); }); it('should not throw if extension directory does not exist', async () => { fs.rmSync(userExtensionsDir, { recursive: true, force: true }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toEqual([]); }); it('should throw if there are duplicate extension names', async () => { // We manually create two extensions with different dirs but same name in config const ext1Dir = path.join(userExtensionsDir, 'ext1-dir'); const ext2Dir = path.join(userExtensionsDir, 'ext2-dir'); fs.mkdirSync(ext1Dir, { recursive: true }); fs.mkdirSync(ext2Dir, { recursive: true }); const config = JSON.stringify({ name: 'duplicate-ext', version: '1.0.0', }); fs.writeFileSync(path.join(ext1Dir, 'gemini-extension.json'), config); fs.writeFileSync( path.join(ext1Dir, 'metadata.json'), JSON.stringify({ type: 'local', source: ext1Dir }), ); fs.writeFileSync(path.join(ext2Dir, 'gemini-extension.json'), config); fs.writeFileSync( path.join(ext2Dir, 'metadata.json'), JSON.stringify({ type: 'local', source: ext2Dir }), ); await expect(extensionManager.loadExtensions()).rejects.toThrow( 'Extension with name duplicate-ext already was loaded.', ); }); it('should wait for loadExtensions to finish when loadExtension is called concurrently', async () => { // Create an initial extension that loadExtensions will find createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); // Start the parallel load (it will read ext1) const loadAllPromise = extensionManager.loadExtensions(); // Create a second extension dynamically in a DIFFERENT directory // so that loadExtensions (which scans userExtensionsDir) doesn't find it. const externalDir = fs.mkdtempSync( path.join(os.tmpdir(), 'external-ext-'), ); fs.writeFileSync( path.join(externalDir, 'gemini-extension.json'), JSON.stringify({ name: 'ext2', version: '1.0.0' }), ); fs.writeFileSync( path.join(externalDir, 'metadata.json'), JSON.stringify({ type: 'local', source: externalDir }), ); // Concurrently call loadExtension (simulating an install or update) const loadSinglePromise = extensionManager.loadExtension(externalDir); // Wait for both to complete await Promise.all([loadAllPromise, loadSinglePromise]); // Both extensions should now be present in the loadedExtensions array const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(2); const names = extensions.map((ext) => ext.name).sort(); expect(names).toEqual(['ext1', 'ext2']); fs.rmSync(externalDir, { recursive: true, force: true }); }); }); describe('symlink handling', () => { let extensionDir: string; let symlinkDir: string; beforeEach(() => { extensionDir = path.join(tempHomeDir, 'extension'); symlinkDir = path.join(tempHomeDir, 'symlink-ext'); fs.mkdirSync(extensionDir, { recursive: true }); fs.writeFileSync( path.join(extensionDir, 'gemini-extension.json'), JSON.stringify({ name: 'test-ext', version: '1.0.0' }), ); fs.symlinkSync(extensionDir, symlinkDir, 'dir'); }); it('preserves symlinks in installMetadata.source when linking', async () => { const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings: { security: { folderTrust: { enabled: false }, // Disable trust for simplicity in this test }, experimental: { extensionConfig: false }, admin: { extensions: { enabled: true }, mcp: { enabled: true } }, hooksConfig: { enabled: true }, } as unknown as MergedSettings, requestConsent: () => Promise.resolve(true), requestSetting: null, integrityManager: mockIntegrityManager, }); // Trust the workspace to allow installation const trustedFolders = loadTrustedFolders(); await trustedFolders.setValue(tempWorkspaceDir, TrustLevel.TRUST_FOLDER); const installMetadata = { source: symlinkDir, type: 'link' as const, }; await manager.loadExtensions(); const extension = await manager.installOrUpdateExtension(installMetadata); // Desired behavior: it preserves symlinks (if they were absolute or relative as provided) expect(extension.installMetadata?.source).toBe(symlinkDir); }); it('works with the new install command logic (preserves symlink but trusts real path)', async () => { // This simulates the logic in packages/cli/src/commands/extensions/install.ts const absolutePath = path.resolve(symlinkDir); const realPath = getRealPath(absolutePath); const settings = { security: { folderTrust: { enabled: true }, }, experimental: { extensionConfig: false }, admin: { extensions: { enabled: true }, mcp: { enabled: true } }, hooksConfig: { enabled: true }, } as unknown as MergedSettings; // Trust the REAL path const trustedFolders = loadTrustedFolders(); await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); // Check trust of the symlink path const trustResult = isWorkspaceTrusted(settings, absolutePath); expect(trustResult.isTrusted).toBe(true); const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings, requestConsent: () => Promise.resolve(true), requestSetting: null, integrityManager: mockIntegrityManager, }); const installMetadata = { source: absolutePath, type: 'link' as const, }; await manager.loadExtensions(); const extension = await manager.installOrUpdateExtension(installMetadata); expect(extension.installMetadata?.source).toBe(absolutePath); expect(extension.installMetadata?.source).not.toBe(realPath); }); it('enforces allowedExtensions using the real path', async () => { const absolutePath = path.resolve(symlinkDir); const realPath = getRealPath(absolutePath); const settings = { security: { folderTrust: { enabled: false }, // Only allow the real path, not the symlink path allowedExtensions: [realPath.replace(/\\/g, '\\\\')], }, experimental: { extensionConfig: false }, admin: { extensions: { enabled: true }, mcp: { enabled: true } }, hooksConfig: { enabled: true }, } as unknown as MergedSettings; const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings, requestConsent: () => Promise.resolve(true), requestSetting: null, integrityManager: mockIntegrityManager, }); const installMetadata = { source: absolutePath, type: 'link' as const, }; await manager.loadExtensions(); // This should pass because realPath is allowed const extension = await manager.installOrUpdateExtension(installMetadata); expect(extension.name).toBe('test-ext'); // Now try with a settings that only allows the symlink path string const settingsOnlySymlink = { security: { folderTrust: { enabled: false }, // Only allow the symlink path string explicitly allowedExtensions: [absolutePath.replace(/\\/g, '\\\\')], }, experimental: { extensionConfig: false }, admin: { extensions: { enabled: true }, mcp: { enabled: true } }, hooksConfig: { enabled: true }, } as unknown as MergedSettings; const manager2 = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings: settingsOnlySymlink, requestConsent: () => Promise.resolve(true), requestSetting: null, integrityManager: mockIntegrityManager, }); // This should FAIL because it checks the real path against the pattern // (Unless symlinkDir === extensionDir, which shouldn't happen in this test setup) if (absolutePath !== realPath) { await expect( manager2.installOrUpdateExtension(installMetadata), ).rejects.toThrow( /is not allowed by the "allowedExtensions" security setting/, ); } }); }); describe('Extension Renaming', () => { it('should support renaming an extension during update', async () => { // 1. Setup existing extension const oldName = 'old-name'; const newName = 'new-name'; const extDir = path.join(userExtensionsDir, oldName); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, 'gemini-extension.json'), JSON.stringify({ name: oldName, version: '1.0.0' }), ); fs.writeFileSync( path.join(extDir, 'metadata.json'), JSON.stringify({ type: 'local', source: extDir }), ); await extensionManager.loadExtensions(); // 2. Create a temporary "new" version with a different name const newSourceDir = fs.mkdtempSync( path.join(tempHomeDir, 'new-source-'), ); fs.writeFileSync( path.join(newSourceDir, 'gemini-extension.json'), JSON.stringify({ name: newName, version: '1.1.0' }), ); fs.writeFileSync( path.join(newSourceDir, 'metadata.json'), JSON.stringify({ type: 'local', source: newSourceDir }), ); // 3. Update the extension await extensionManager.installOrUpdateExtension( { type: 'local', source: newSourceDir }, { name: oldName, version: '1.0.0' }, ); // 4. Verify old directory is gone and new one exists expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false); expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true); // Verify the loaded state is updated const extensions = extensionManager.getExtensions(); expect(extensions.some((e) => e.name === newName)).toBe(true); expect(extensions.some((e) => e.name === oldName)).toBe(false); }); it('should carry over enablement status when renaming', async () => { const oldName = 'old-name'; const newName = 'new-name'; const extDir = path.join(userExtensionsDir, oldName); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, 'gemini-extension.json'), JSON.stringify({ name: oldName, version: '1.0.0' }), ); fs.writeFileSync( path.join(extDir, 'metadata.json'), JSON.stringify({ type: 'local', source: extDir }), ); // Enable it const enablementManager = extensionManager.getEnablementManager(); enablementManager.enable(oldName, true, tempHomeDir); await extensionManager.loadExtensions(); const extension = extensionManager.getExtensions()[0]; expect(extension.isActive).toBe(true); const newSourceDir = fs.mkdtempSync( path.join(tempHomeDir, 'new-source-'), ); fs.writeFileSync( path.join(newSourceDir, 'gemini-extension.json'), JSON.stringify({ name: newName, version: '1.1.0' }), ); fs.writeFileSync( path.join(newSourceDir, 'metadata.json'), JSON.stringify({ type: 'local', source: newSourceDir }), ); await extensionManager.installOrUpdateExtension( { type: 'local', source: newSourceDir }, { name: oldName, version: '1.0.0' }, ); // Verify new name is enabled expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true); // Verify old name is removed from enablement expect(enablementManager.readConfig()[oldName]).toBeUndefined(); }); it('should prevent renaming if the new name conflicts with an existing extension', async () => { // Setup two extensions const ext1Dir = path.join(userExtensionsDir, 'ext1'); fs.mkdirSync(ext1Dir, { recursive: true }); fs.writeFileSync( path.join(ext1Dir, 'gemini-extension.json'), JSON.stringify({ name: 'ext1', version: '1.0.0' }), ); fs.writeFileSync( path.join(ext1Dir, 'metadata.json'), JSON.stringify({ type: 'local', source: ext1Dir }), ); const ext2Dir = path.join(userExtensionsDir, 'ext2'); fs.mkdirSync(ext2Dir, { recursive: true }); fs.writeFileSync( path.join(ext2Dir, 'gemini-extension.json'), JSON.stringify({ name: 'ext2', version: '1.0.0' }), ); fs.writeFileSync( path.join(ext2Dir, 'metadata.json'), JSON.stringify({ type: 'local', source: ext2Dir }), ); await extensionManager.loadExtensions(); // Try to update ext1 to name 'ext2' const newSourceDir = fs.mkdtempSync( path.join(tempHomeDir, 'new-source-'), ); fs.writeFileSync( path.join(newSourceDir, 'gemini-extension.json'), JSON.stringify({ name: 'ext2', version: '1.1.0' }), ); fs.writeFileSync( path.join(newSourceDir, 'metadata.json'), JSON.stringify({ type: 'local', source: newSourceDir }), ); await expect( extensionManager.installOrUpdateExtension( { type: 'local', source: newSourceDir }, { name: 'ext1', version: '1.0.0' }, ), ).rejects.toThrow(/already installed/); }); }); describe('extension integrity', () => { it('should store integrity data during installation', async () => { const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); const extDir = path.join(tempHomeDir, 'new-integrity-ext'); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, 'gemini-extension.json'), JSON.stringify({ name: 'integrity-ext', version: '1.0.0' }), ); const installMetadata = { source: extDir, type: 'local' as const, }; await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension(installMetadata); expect(storeSpy).toHaveBeenCalledWith('integrity-ext', installMetadata); }); it('should store integrity data during first update', async () => { const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); const verifySpy = vi.spyOn(extensionManager, 'verifyExtensionIntegrity'); // Setup existing extension const extName = 'update-integrity-ext'; const extDir = path.join(userExtensionsDir, extName); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, 'gemini-extension.json'), JSON.stringify({ name: extName, version: '1.0.0' }), ); fs.writeFileSync( path.join(extDir, 'metadata.json'), JSON.stringify({ type: 'local', source: extDir }), ); await extensionManager.loadExtensions(); // Ensure no integrity data exists for this extension verifySpy.mockResolvedValueOnce(IntegrityDataStatus.MISSING); const initialStatus = await extensionManager.verifyExtensionIntegrity( extName, { type: 'local', source: extDir }, ); expect(initialStatus).toBe('missing'); // Create new version of the extension const newSourceDir = fs.mkdtempSync( path.join(tempHomeDir, 'new-source-'), ); fs.writeFileSync( path.join(newSourceDir, 'gemini-extension.json'), JSON.stringify({ name: extName, version: '1.1.0' }), ); const installMetadata = { source: newSourceDir, type: 'local' as const, }; // Perform update and verify integrity was stored await extensionManager.installOrUpdateExtension(installMetadata, { name: extName, version: '1.0.0', }); expect(storeSpy).toHaveBeenCalledWith(extName, installMetadata); }); }); describe('early theme registration', () => { it('should register themes with ThemeManager during loadExtensions for active extensions', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'themed-ext', version: '1.0.0', themes: [testTheme], }); await extensionManager.loadExtensions(); expect(themeManager.getCustomThemeNames()).toContain( 'MyTheme (themed-ext)', ); }); it('should not register themes for inactive extensions', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'disabled-ext', version: '1.0.0', themes: [testTheme], }); // Disable the extension by creating an enablement override const manager = new ExtensionManager({ enabledExtensionOverrides: ['none'], settings: createTestMergedSettings(), workspaceDir: tempWorkspaceDir, requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, }); await manager.loadExtensions(); expect(themeManager.getCustomThemeNames()).not.toContain( 'MyTheme (disabled-ext)', ); }); }); describe('orphaned extension cleanup', () => { it('should remove broken extension metadata on startup to allow re-installation', async () => { const extName = 'orphaned-ext'; const sourceDir = path.join(tempHomeDir, 'valid-source'); fs.mkdirSync(sourceDir, { recursive: true }); fs.writeFileSync( path.join(sourceDir, 'gemini-extension.json'), JSON.stringify({ name: extName, version: '1.0.0' }), ); // Link an extension successfully. await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceDir, type: 'link', }); const destinationPath = path.join(userExtensionsDir, extName); const metadataPath = path.join( destinationPath, '.gemini-extension-install.json', ); expect(fs.existsSync(metadataPath)).toBe(true); // Simulate metadata corruption (e.g., pointing to a non-existent source). fs.writeFileSync( metadataPath, JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }), ); // Simulate CLI startup. The manager should detect the broken link // and proactively delete the orphaned metadata directory. const newManager = new ExtensionManager({ settings: createTestMergedSettings(), workspaceDir: tempWorkspaceDir, requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, integrityManager: mockIntegrityManager, }); await newManager.loadExtensions(); // Verify the extension failed to load and was proactively cleaned up. expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( false, ); expect(fs.existsSync(destinationPath)).toBe(false); // Verify the system is self-healed and allows re-linking to the valid source. await newManager.installOrUpdateExtension({ source: sourceDir, type: 'link', }); expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( true, ); }); }); }); ================================================ FILE: packages/cli/src/config/extension-manager.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { type MergedSettings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { isWorkspaceTrusted, loadTrustedFolders, TrustLevel, } from './trustedFolders.js'; import { cloneFromGit, downloadFromGitHubRelease, tryParseGithubUrl, } from './extensions/github.js'; import { Config, debugLogger, ExtensionDisableEvent, ExtensionEnableEvent, ExtensionInstallEvent, ExtensionLoader, ExtensionUninstallEvent, ExtensionUpdateEvent, getErrorMessage, getRealPath, logExtensionDisable, logExtensionEnable, logExtensionInstallEvent, logExtensionUninstall, logExtensionUpdateEvent, loadSkillsFromDir, loadAgentsFromDirectory, homedir, ExtensionIntegrityManager, type IExtensionIntegrity, type IntegrityDataStatus, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, type GeminiCLIExtension, type HookDefinition, type HookEventName, type ResolvedExtensionSetting, coreEvents, applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, loadExtensionPolicies, isSubpath, type PolicyRule, type SafetyCheckerRule, HookType, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { ExtensionStorage } from './extensions/storage.js'; import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, type JsonObject, type VariableContext, } from './extensions/variables.js'; import { getEnvContents, getEnvFilePath, maybePromptForSettings, getMissingSettings, type ExtensionSetting, getScopedEnvContents, ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { themeManager } from '../ui/themes/theme-manager.js'; import { getFormattedSettingValue } from '../commands/extensions/utils.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; settings: MergedSettings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; eventEmitter?: EventEmitter; clientVersion?: string; integrityManager?: IExtensionIntegrity; } /** * Actual implementation of an ExtensionLoader. * * You must call `loadExtensions` prior to calling other methods on this class. */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; private integrityManager: IExtensionIntegrity; private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) | undefined; private telemetryConfig: Config; private workspaceDir: string; private loadedExtensions: GeminiCLIExtension[] | undefined; private loadingPromise: Promise | null = null; constructor(options: ExtensionManagerParams) { super(options.eventEmitter); this.workspaceDir = options.workspaceDir; this.extensionEnablementManager = new ExtensionEnablementManager( options.enabledExtensionOverrides, ); this.settings = options.settings; this.telemetryConfig = new Config({ telemetry: options.settings.telemetry, interactive: false, sessionId: randomUUID(), clientVersion: options.clientVersion ?? 'unknown', targetDir: options.workspaceDir, cwd: options.workspaceDir, model: '', debugMode: false, }); this.requestConsent = options.requestConsent; this.requestSetting = options.requestSetting ?? undefined; this.integrityManager = options.integrityManager ?? new ExtensionIntegrityManager(); } getEnablementManager(): ExtensionEnablementManager { return this.extensionEnablementManager; } async verifyExtensionIntegrity( extensionName: string, metadata: ExtensionInstallMetadata | undefined, ): Promise { return this.integrityManager.verify(extensionName, metadata); } async storeExtensionIntegrity( extensionName: string, metadata: ExtensionInstallMetadata, ): Promise { return this.integrityManager.store(extensionName, metadata); } setRequestConsent( requestConsent: (consent: string) => Promise, ): void { this.requestConsent = requestConsent; } setRequestSetting( requestSetting?: (setting: ExtensionSetting) => Promise, ): void { this.requestSetting = requestSetting; } getExtensions(): GeminiCLIExtension[] { if (!this.loadedExtensions) { throw new Error( 'Extensions not yet loaded, must call `loadExtensions` first', ); } return this.loadedExtensions; } async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, requestConsentOverride?: (consent: string) => Promise, ): Promise { if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { return new RegExp(pattern).test( getRealPath(installMetadata.source), ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, ); } }, ); if (!extensionAllowed) { throw new Error( `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, ); } } else if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && this.settings.security.blockGitExtensions ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', ); } const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; let extension: GeminiCLIExtension | null; try { if (!isWorkspaceTrusted(this.settings).isTrusted) { if ( await this.requestConsent( `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, ) ) { const trustedFolders = loadTrustedFolders(); await trustedFolders.setValue( this.workspaceDir, TrustLevel.TRUST_FOLDER, ); } else { throw new Error( `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, ); } } const extensionsDir = ExtensionStorage.getUserExtensionsDir(); await fs.promises.mkdir(extensionsDir, { recursive: true }); if (installMetadata.type === 'local' || installMetadata.type === 'link') { installMetadata.source = path.isAbsolute(installMetadata.source) ? installMetadata.source : path.resolve(this.workspaceDir, installMetadata.source); } let tempDir: string | undefined; if ( installMetadata.type === 'git' || installMetadata.type === 'github-release' ) { tempDir = await ExtensionStorage.createTmpDir(); const parsedGithubParts = tryParseGithubUrl(installMetadata.source); if (!parsedGithubParts) { await cloneFromGit(installMetadata, tempDir); installMetadata.type = 'git'; } else { const result = await downloadFromGitHubRelease( installMetadata, tempDir, parsedGithubParts, ); if (result.success) { installMetadata.type = result.type; installMetadata.releaseTag = result.tagName; } else if ( // This repo has no github releases, and wasn't explicitly installed // from a github release, unconditionally just clone it. (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, )) ) { await cloneFromGit(installMetadata, tempDir); installMetadata.type = 'git'; } else { throw new Error( `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, ); } } localSourcePath = tempDir; } else if ( installMetadata.type === 'local' || installMetadata.type === 'link' ) { localSourcePath = getRealPath(installMetadata.source); } else { throw new Error(`Unsupported install type: ${installMetadata.type}`); } try { newExtensionConfig = await this.loadExtensionConfig(localSourcePath); const newExtensionName = newExtensionConfig.name; const previousName = previousExtensionConfig?.name ?? newExtensionName; const previous = this.getExtensions().find( (installed) => installed.name === previousName, ); const nameConflict = this.getExtensions().find( (installed) => installed.name === newExtensionName && installed.name !== previousName, ); if (isUpdate && !previous) { throw new Error( `Extension "${previousName}" was not already installed, cannot update it.`, ); } else if (!isUpdate && previous) { throw new Error( `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); } else if (isUpdate && nameConflict) { throw new Error( `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, ); } const newHasHooks = fs.existsSync( path.join(localSourcePath, 'hooks', 'hooks.json'), ); const previousHasHooks = !!( isUpdate && previous && previous.hooks && Object.keys(previous.hooks).length > 0 ); const newSkills = await loadSkillsFromDir( path.join(localSourcePath, 'skills'), ); const previousSkills = previous?.skills ?? []; const isMigrating = Boolean( previous && previous.installMetadata && previous.installMetadata.source !== installMetadata.source, ); await maybeRequestConsentOrFail( newExtensionConfig, requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, newSkills, previousSkills, isMigrating, ); const extensionId = getExtensionId(newExtensionConfig, installMetadata); const destinationPath = new ExtensionStorage( newExtensionName, ).getExtensionDir(); if ( (!isUpdate || newExtensionName !== previousName) && fs.existsSync(destinationPath) ) { throw new Error( `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, ); } let previousSettings: Record | undefined; let wasEnabledGlobally = false; let wasEnabledWorkspace = false; if (isUpdate && previousExtensionConfig) { const previousExtensionId = previous?.installMetadata ? getExtensionId(previousExtensionConfig, previous.installMetadata) : extensionId; previousSettings = await getEnvContents( previousExtensionConfig, previousExtensionId, this.workspaceDir, ); if (newExtensionName !== previousName) { wasEnabledGlobally = this.extensionEnablementManager.isEnabled( previousName, homedir(), ); wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( previousName, this.workspaceDir, ); this.extensionEnablementManager.remove(previousName); } await this.uninstallExtension(previousName, isUpdate); } await fs.promises.mkdir(destinationPath, { recursive: true }); if (this.requestSetting && this.settings.experimental.extensionConfig) { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, extensionId, this.requestSetting, previousExtensionConfig, previousSettings, ); } else { await maybePromptForSettings( newExtensionConfig, extensionId, this.requestSetting, ); } } const missingSettings = this.settings.experimental.extensionConfig ? await getMissingSettings( newExtensionConfig, extensionId, this.workspaceDir, ) : []; if (missingSettings.length > 0) { const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings .map((s) => s.name) .join( ', ', )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; debugLogger.warn(message); coreEvents.emitFeedback('warning', message); } if ( installMetadata.type === 'local' || installMetadata.type === 'git' || installMetadata.type === 'github-release' ) { await copyExtension(localSourcePath, destinationPath); } const metadataString = JSON.stringify(installMetadata, null, 2); const metadataPath = path.join( destinationPath, INSTALL_METADATA_FILENAME, ); await fs.promises.writeFile(metadataPath, metadataString); // Establish trust at point of installation await this.storeExtensionIntegrity( newExtensionConfig.name, installMetadata, ); // TODO: Gracefully handle this call failing, we should back up the old // extension prior to overwriting it and then restore and restart it. extension = await this.loadExtension(destinationPath); if (!extension) { throw new Error(`Extension not found`); } if (isUpdate) { await logExtensionUpdateEvent( this.telemetryConfig, new ExtensionUpdateEvent( newExtensionConfig.name, hashValue(newExtensionConfig.name), getExtensionId(newExtensionConfig, installMetadata), newExtensionConfig.version, previousExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Success, ), ); if (newExtensionName !== previousName) { if (wasEnabledGlobally) { await this.enableExtension(newExtensionName, SettingScope.User); } if (wasEnabledWorkspace) { await this.enableExtension( newExtensionName, SettingScope.Workspace, ); } } } else { await logExtensionInstallEvent( this.telemetryConfig, new ExtensionInstallEvent( newExtensionConfig.name, hashValue(newExtensionConfig.name), getExtensionId(newExtensionConfig, installMetadata), newExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Success, ), ); await this.enableExtension( newExtensionConfig.name, SettingScope.User, ); } } finally { if (tempDir) { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } return extension; } catch (error) { // Attempt to load config from the source path even if installation fails // to get the name and version for logging. if (!newExtensionConfig && localSourcePath) { try { newExtensionConfig = await this.loadExtensionConfig(localSourcePath); } catch { // Ignore error, this is just for logging. } } const config = newExtensionConfig ?? previousExtensionConfig; const extensionId = config ? getExtensionId(config, installMetadata) : undefined; if (isUpdate) { await logExtensionUpdateEvent( this.telemetryConfig, new ExtensionUpdateEvent( config?.name ?? '', hashValue(config?.name ?? ''), extensionId ?? '', newExtensionConfig?.version ?? '', previousExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Error, ), ); } else { await logExtensionInstallEvent( this.telemetryConfig, new ExtensionInstallEvent( newExtensionConfig?.name ?? '', hashValue(newExtensionConfig?.name ?? ''), extensionId ?? '', newExtensionConfig?.version ?? '', installMetadata.type, CoreToolCallStatus.Error, ), ); } throw error; } } async uninstallExtension( extensionIdentifier: string, isUpdate: boolean, ): Promise { const installedExtensions = this.getExtensions(); const extension = installedExtensions.find( (installed) => installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || installed.installMetadata?.source.toLowerCase() === extensionIdentifier.toLowerCase(), ); if (!extension) { throw new Error(`Extension not found.`); } await this.unloadExtension(extension); const storage = new ExtensionStorage( extension.installMetadata?.type === 'link' ? extension.name : path.basename(extension.path), ); await fs.promises.rm(storage.getExtensionDir(), { recursive: true, force: true, }); // The rest of the cleanup below here is only for true uninstalls, not // uninstalls related to updates. if (isUpdate) return; this.extensionEnablementManager.remove(extension.name); await logExtensionUninstall( this.telemetryConfig, new ExtensionUninstallEvent( extension.name, hashValue(extension.name), extension.id, CoreToolCallStatus.Success, ), ); } protected override async startExtension(extension: GeminiCLIExtension) { await super.startExtension(extension); if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) { themeManager.registerExtensionThemes(extension.name, extension.themes); } } protected override async stopExtension(extension: GeminiCLIExtension) { await super.stopExtension(extension); if (extension.themes) { themeManager.unregisterExtensionThemes(extension.name, extension.themes); } } /** * Loads all installed extensions, should only be called once. */ async loadExtensions(): Promise { if (this.loadedExtensions) { throw new Error('Extensions already loaded, only load extensions once.'); } if (this.loadingPromise) { return this.loadingPromise; } this.loadingPromise = (async () => { try { if (this.settings.admin.extensions.enabled === false) { this.loadedExtensions = []; return this.loadedExtensions; } const extensionsDir = ExtensionStorage.getUserExtensionsDir(); if (!fs.existsSync(extensionsDir)) { this.loadedExtensions = []; return this.loadedExtensions; } const subdirs = await fs.promises.readdir(extensionsDir); const extensionPromises = subdirs.map((subdir) => { const extensionDir = path.join(extensionsDir, subdir); return this._buildExtension(extensionDir); }); const builtExtensionsOrNull = await Promise.all(extensionPromises); const builtExtensions = builtExtensionsOrNull.filter( (ext): ext is GeminiCLIExtension => ext !== null, ); const seenNames = new Set(); for (const ext of builtExtensions) { if (seenNames.has(ext.name)) { throw new Error( `Extension with name ${ext.name} already was loaded.`, ); } seenNames.add(ext.name); } this.loadedExtensions = builtExtensions; // Register extension themes early so they're available at startup. for (const ext of this.loadedExtensions) { if (ext.isActive && ext.themes) { themeManager.registerExtensionThemes(ext.name, ext.themes); } } await Promise.all( this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)), ); return this.loadedExtensions; } finally { this.loadingPromise = null; } })(); return this.loadingPromise; } /** * Adds `extension` to the list of extensions and starts it if appropriate. * * @internal visible for testing only */ async loadExtension( extensionDir: string, ): Promise { if (this.loadingPromise) { await this.loadingPromise; } this.loadedExtensions ??= []; const extension = await this._buildExtension(extensionDir); if (!extension) { return null; } if ( this.getExtensions().find( (installed) => installed.name === extension.name, ) ) { throw new Error( `Extension with name ${extension.name} already was loaded.`, ); } this.loadedExtensions = [...this.loadedExtensions, extension]; await this.maybeStartExtension(extension); return extension; } /** * Builds an extension without side effects (does not mutate loadedExtensions or start it). */ private async _buildExtension( extensionDir: string, ): Promise { try { const stats = await fs.promises.stat(extensionDir); if (!stats.isDirectory()) { return null; } } catch { return null; } const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { if (!installMetadata?.source) { throw new Error( `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, ); } const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { return new RegExp(pattern).test( getRealPath(installMetadata?.source ?? ''), ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, ); } }, ); if (!extensionAllowed) { debugLogger.warn( `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, ); return null; } } else if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && this.settings.security.blockGitExtensions ) { debugLogger.warn( `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, ); return null; } if (installMetadata?.type === 'link') { effectiveExtensionPath = installMetadata.source; } try { let config = await this.loadExtensionConfig(effectiveExtensionPath); const extensionId = getExtensionId(config, installMetadata); let userSettings: Record = {}; let workspaceSettings: Record = {}; if (this.settings.experimental.extensionConfig) { userSettings = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.USER, ); if (isWorkspaceTrusted(this.settings).isTrusted) { workspaceSettings = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.WORKSPACE, this.workspaceDir, ); } } const customEnv = { ...userSettings, ...workspaceSettings }; config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; if (config.settings && this.settings.experimental.extensionConfig) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; let scope: 'user' | 'workspace' | undefined; let source: string | undefined; // Note: strict check for undefined, as empty string is a valid value if (workspaceSettings[setting.envVar] !== undefined) { scope = 'workspace'; if (setting.sensitive) { source = 'Keychain'; } else { source = getEnvFilePath( config.name, ExtensionSettingScope.WORKSPACE, this.workspaceDir, ); } } else if (userSettings[setting.envVar] !== undefined) { scope = 'user'; if (setting.sensitive) { source = 'Keychain'; } else { source = getEnvFilePath(config.name, ExtensionSettingScope.USER); } } resolvedSettings.push({ name: setting.name, envVar: setting.envVar, value, sensitive: setting.sensitive ?? false, scope, source, }); } } if (config.mcpServers) { if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { // Apply admin allowlist if configured const adminAllowlist = this.settings.admin.mcp.config; if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { const result = applyAdminAllowlist( config.mcpServers, adminAllowlist, ); config.mcpServers = result.mcpServers; if (result.blockedServerNames.length > 0) { const message = getAdminBlockedMcpServersMessage( result.blockedServerNames, undefined, ); coreEvents.emitConsoleLog('warn', message); } } // Then apply local filtering/sanitization if (config.mcpServers) { config.mcpServers = Object.fromEntries( Object.entries(config.mcpServers).map(([key, value]) => [ key, filterMcpConfig(value), ]), ); } } } const contextFiles = getContextFileNames(config) .map((contextFileName) => { const contextFilePath = path.join( effectiveExtensionPath, contextFileName, ); if (!isSubpath(effectiveExtensionPath, contextFilePath)) { throw new Error( `Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`, ); } return contextFilePath; }) .filter((contextFilePath) => fs.existsSync(contextFilePath)); const hydrationContext: VariableContext = { extensionPath: effectiveExtensionPath, workspacePath: this.workspaceDir, '/': path.sep, pathSeparator: path.sep, ...customEnv, }; let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; if (this.settings.hooksConfig.enabled) { hooks = await this.loadExtensionHooks( effectiveExtensionPath, hydrationContext, ); } // Hydrate hooks with extension settings as environment variables if (hooks && config.settings) { const hookEnv: Record = {}; for (const setting of config.settings) { const value = customEnv[setting.envVar]; if (value !== undefined) { hookEnv[setting.envVar] = value; } } if (Object.keys(hookEnv).length > 0) { for (const eventName of Object.keys(hooks)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const eventHooks = hooks[eventName as HookEventName]; if (eventHooks) { for (const definition of eventHooks) { for (const hook of definition.hooks) { if (hook.type === HookType.Command) { // Merge existing env with new env vars, giving extension settings precedence. hook.env = { ...hook.env, ...hookEnv }; } } } } } } } let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); skills = skills.map((skill) => ({ ...recursivelyHydrateStrings(skill, hydrationContext), extensionName: config.name, })); let rules: PolicyRule[] | undefined; let checkers: SafetyCheckerRule[] | undefined; const policyDir = path.join(effectiveExtensionPath, 'policies'); if (fs.existsSync(policyDir)) { const result = await loadExtensionPolicies(config.name, policyDir); rules = result.rules; checkers = result.checkers; if (result.errors.length > 0) { for (const error of result.errors) { debugLogger.warn( `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, ); } } } const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ ...recursivelyHydrateStrings(agent, hydrationContext), extensionName: config.name, })); // Log errors but don't fail the entire extension load for (const error of agentLoadResult.errors) { debugLogger.warn( `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, ); } return { name: config.name, version: config.version, path: effectiveExtensionPath, contextFiles, installMetadata, migratedTo: config.migratedTo, mcpServers: config.mcpServers, excludeTools: config.excludeTools, hooks, isActive: this.extensionEnablementManager.isEnabled( config.name, this.workspaceDir, ), id: getExtensionId(config, installMetadata), settings: config.settings, resolvedSettings, skills, agents: agentLoadResult.agents, themes: config.themes, rules, checkers, plan: config.plan, }; } catch (e) { const extName = path.basename(extensionDir); debugLogger.warn( `Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`, ); try { await fs.promises.rm(extensionDir, { recursive: true, force: true }); } catch (rmError) { debugLogger.error( `Failed to remove broken extension directory ${extensionDir}:`, rmError, ); } return null; } } override async restartExtension( extension: GeminiCLIExtension, ): Promise { const extensionDir = extension.path; await this.unloadExtension(extension); await this.loadExtension(extensionDir); } /** * Removes `extension` from the list of extensions and stops it if * appropriate. */ private unloadExtension( extension: GeminiCLIExtension, ): Promise | undefined { this.loadedExtensions = this.getExtensions().filter( (entry) => extension !== entry, ); return this.maybeStopExtension(extension); } async loadExtensionConfig(extensionDir: string): Promise { const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { throw new Error(`Configuration file not found at ${configFilePath}`); } try { const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const rawConfig = JSON.parse(configContent) as ExtensionConfig; if (!rawConfig.name || !rawConfig.version) { throw new Error( `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawConfig as unknown as JsonObject, { extensionPath: extensionDir, workspacePath: this.workspaceDir, '/': path.sep, pathSeparator: path.sep, }, ) as unknown as ExtensionConfig; validateName(config.name); return config; } catch (e) { throw new Error( `Failed to load extension config from ${configFilePath}: ${getErrorMessage( e, )}`, ); } } private async loadExtensionHooks( extensionDir: string, context: VariableContext, ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); try { const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawHooks = JSON.parse(hooksContent); if ( !rawHooks || typeof rawHooks !== 'object' || typeof rawHooks.hooks !== 'object' || rawHooks.hooks === null || Array.isArray(rawHooks.hooks) ) { debugLogger.warn( `Invalid hooks configuration in ${hooksFilePath}: "hooks" property must be an object`, ); return undefined; } // Hydrate variables in the hooks configuration const hydratedHooks = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawHooks.hooks as unknown as JsonObject, { ...context, '/': path.sep, pathSeparator: path.sep, }, ) as { [K in HookEventName]?: HookDefinition[] }; return hydratedHooks; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((e as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; // File not found is not an error here. } debugLogger.warn( `Failed to load extension hooks from ${hooksFilePath}: ${getErrorMessage( e, )}`, ); return undefined; } } toOutputString(extension: GeminiCLIExtension): string { const userEnabled = this.extensionEnablementManager.isEnabled( extension.name, homedir(), ); const workspaceEnabled = this.extensionEnablementManager.isEnabled( extension.name, this.workspaceDir, ); const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); let output = `${status} ${extension.name} (${extension.version})`; output += `\n ID: ${extension.id}`; output += `\n name: ${hashValue(extension.name)}`; output += `\n Path: ${extension.path}`; if (extension.installMetadata) { output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; if (extension.installMetadata.ref) { output += `\n Ref: ${extension.installMetadata.ref}`; } if (extension.installMetadata.releaseTag) { output += `\n Release tag: ${extension.installMetadata.releaseTag}`; } } output += `\n Enabled (User): ${userEnabled}`; output += `\n Enabled (Workspace): ${workspaceEnabled}`; if (extension.contextFiles.length > 0) { output += `\n Context files:`; extension.contextFiles.forEach((contextFile) => { output += `\n ${contextFile}`; }); } if (extension.mcpServers) { output += `\n MCP servers:`; Object.keys(extension.mcpServers).forEach((key) => { output += `\n ${key}`; }); } if (extension.excludeTools) { output += `\n Excluded tools:`; extension.excludeTools.forEach((tool) => { output += `\n ${tool}`; }); } if (extension.skills && extension.skills.length > 0) { output += `\n Agent skills:`; extension.skills.forEach((skill) => { output += `\n ${skill.name}: ${skill.description}`; }); } const resolvedSettings = extension.resolvedSettings; if (resolvedSettings && resolvedSettings.length > 0) { output += `\n Settings:`; resolvedSettings.forEach((setting) => { let scope = ''; if (setting.scope) { scope = setting.scope === 'workspace' ? '(Workspace' : '(User'; if (setting.source) { scope += ` - ${setting.source}`; } scope += ')'; } output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`; }); } return output; } async disableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = this.getExtensions().find( (extension) => extension.name === name, ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } if (scope !== SettingScope.Session) { const scopePath = scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.disable(name, true, scopePath); } await logExtensionDisable( this.telemetryConfig, new ExtensionDisableEvent(name, hashValue(name), extension.id, scope), ); if (!this.config || this.config.getEnableExtensionReloading()) { // Only toggle the isActive state if we are actually going to disable it // in the current session, or we haven't been initialized yet. extension.isActive = false; } await this.maybeStopExtension(extension); } /** * Enables an existing extension for a given scope, and starts it if * appropriate. */ async enableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = this.getExtensions().find( (extension) => extension.name === name, ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } if (scope !== SettingScope.Session) { const scopePath = scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.enable(name, true, scopePath); } await logExtensionEnable( this.telemetryConfig, new ExtensionEnableEvent(name, hashValue(name), extension.id, scope), ); if (!this.config || this.config.getEnableExtensionReloading()) { // Only toggle the isActive state if we are actually going to disable it // in the current session, or we haven't been initialized yet. extension.isActive = true; } await this.maybeStartExtension(extension); } } function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { trust, ...rest } = original; return Object.freeze(rest); } /** * Recursively ensures that the owner has write permissions for all files * and directories within the target path. */ async function makeWritableRecursive(targetPath: string): Promise { const stats = await fs.promises.lstat(targetPath); if (stats.isDirectory()) { // Ensure directory is rwx for the owner (0o700) await fs.promises.chmod(targetPath, stats.mode | 0o700); const children = await fs.promises.readdir(targetPath); for (const child of children) { await makeWritableRecursive(path.join(targetPath, child)); } } else if (stats.isFile()) { // Ensure file is rw for the owner (0o600) await fs.promises.chmod(targetPath, stats.mode | 0o600); } } export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); await makeWritableRecursive(destination); } function getContextFileNames(config: ExtensionConfig): string[] { if (!config.contextFileName) { return ['GEMINI.md']; } else if (!Array.isArray(config.contextFileName)) { return [config.contextFileName]; } return config.contextFileName; } function validateName(name: string) { if (!/^[a-zA-Z0-9-]+$/.test(name)) { throw new Error( `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, ); } } export async function inferInstallMetadata( source: string, args: { ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; } = {}, ): Promise { if ( source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@') || source.startsWith('sso://') ) { return { source, type: 'git', ref: args.ref, autoUpdate: args.autoUpdate, allowPreRelease: args.allowPreRelease, }; } else { if (args.ref || args.autoUpdate) { throw new Error( '--ref and --auto-update are not applicable for local extensions.', ); } try { await stat(source); return { source, type: 'local', }; } catch { throw new Error('Install source not found.'); } } } export function getExtensionId( config: ExtensionConfig, installMetadata?: ExtensionInstallMetadata, ): string { // IDs are created by hashing details of the installation source in order to // deduplicate extensions with conflicting names and also obfuscate any // potentially sensitive information such as private git urls, system paths, // or project names. let idValue = config.name; const githubUrlParts = installMetadata && (installMetadata.type === 'git' || installMetadata.type === 'github-release') ? tryParseGithubUrl(installMetadata.source) : null; if (githubUrlParts) { // For github repos, we use the https URI to the repo as the ID. idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`; } else { idValue = installMetadata?.source ?? config.name; } return hashValue(idValue); } export function hashValue(value: string): string { return createHash('sha256').update(value).digest('hex'); } ================================================ FILE: packages/cli/src/config/extension.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, type MockedFunction, describe, it, expect, beforeEach, afterEach, afterAll, } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { type GeminiCLIExtension, ExtensionUninstallEvent, ExtensionDisableEvent, ExtensionEnableEvent, KeychainTokenStorage, loadAgentsFromDirectory, loadSkillsFromDir, getRealPath, } from '@google/gemini-cli-core'; import { loadSettings, createTestMergedSettings, SettingScope, resetSettingsCacheForTesting, } from './settings.js'; import { isWorkspaceTrusted, resetTrustedFoldersForTesting, } from './trustedFolders.js'; import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { join } from 'node:path'; import { EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_DIRECTORY_NAME, INSTALL_METADATA_FILENAME, } from './extensions/variables.js'; import { hashValue, ExtensionManager } from './extension-manager.js'; import { ExtensionStorage } from './extensions/storage.js'; import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js'; import type { ExtensionSetting } from './extensions/extensionSettings.js'; const mockGit = { clone: vi.fn(), getRemotes: vi.fn(), fetch: vi.fn(), checkout: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), // Not a part of the actual API, but we need to use this to do the correct // file system interactions. path: vi.fn(), }; const mockDownloadFromGithubRelease = vi.hoisted(() => vi.fn()); vi.mock('./extensions/github.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, downloadFromGitHubRelease: mockDownloadFromGithubRelease, }; }); vi.mock('simple-git', () => ({ simpleGit: vi.fn((path: string) => { mockGit.path.mockReturnValue(path); return mockGit; }), })); const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, homedir: mockHomedir, }; }); vi.mock('./trustedFolders.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isWorkspaceTrusted: vi.fn(), }; }); const mockLogExtensionEnable = vi.hoisted(() => vi.fn()); const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); const mockIntegrityManager = vi.hoisted(() => ({ verify: vi.fn().mockResolvedValue('verified'), store: vi.fn().mockResolvedValue(undefined), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, logExtensionEnable: mockLogExtensionEnable, logExtensionInstallEvent: mockLogExtensionInstallEvent, logExtensionUninstall: mockLogExtensionUninstall, logExtensionUpdateEvent: mockLogExtensionUpdateEvent, logExtensionDisable: mockLogExtensionDisable, homedir: mockHomedir, ExtensionEnableEvent: vi.fn(), ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), ExtensionDisableEvent: vi.fn(), ExtensionIntegrityManager: vi .fn() .mockImplementation(() => mockIntegrityManager), KeychainTokenStorage: vi.fn().mockImplementation(() => ({ getSecret: vi.fn(), setSecret: vi.fn(), deleteSecret: vi.fn(), listSecrets: vi.fn(), isAvailable: vi.fn().mockResolvedValue(true), })), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), loadSkillsFromDir: vi.fn().mockImplementation(async () => []), }; }); vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execSync: vi.fn(), }; }); interface MockKeychainStorage { getSecret: ReturnType; setSecret: ReturnType; deleteSecret: ReturnType; listSecrets: ReturnType; isAvailable: ReturnType; } describe('extension tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; let userExtensionsDir: string; let extensionManager: ExtensionManager; let mockRequestConsent: MockedFunction<(consent: string) => Promise>; let mockPromptForSettings: MockedFunction< (setting: ExtensionSetting) => Promise >; let mockKeychainStorage: MockKeychainStorage; let keychainData: Record; beforeEach(() => { vi.clearAllMocks(); resetSettingsCacheForTesting(); keychainData = {}; mockKeychainStorage = { getSecret: vi .fn() .mockImplementation(async (key: string) => keychainData[key] || null), setSecret: vi .fn() .mockImplementation(async (key: string, value: string) => { keychainData[key] = value; }), deleteSecret: vi.fn().mockImplementation(async (key: string) => { delete keychainData[key]; }), listSecrets: vi .fn() .mockImplementation(async () => Object.keys(keychainData)), isAvailable: vi.fn().mockResolvedValue(true), }; ( KeychainTokenStorage as unknown as ReturnType ).mockImplementation(() => mockKeychainStorage); vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ agents: [], errors: [], }); vi.mocked(loadSkillsFromDir).mockResolvedValue([]); tempHomeDir = getRealPath( fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')), ); tempWorkspaceDir = getRealPath( fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')), ); userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); mockRequestConsent = vi.fn(); mockRequestConsent.mockResolvedValue(true); mockPromptForSettings = vi.fn(); mockPromptForSettings.mockResolvedValue(''); fs.mkdirSync(userExtensionsDir, { recursive: true }); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: undefined, }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); const settings = loadSettings(tempWorkspaceDir).merged; settings.experimental.extensionConfig = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, integrityManager: mockIntegrityManager, }); resetTrustedFoldersForTesting(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); describe('loadExtensions', () => { it('should include extension path in loaded extension', async () => { const extensionDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extensionDir, { recursive: true }); createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].name).toBe('test-extension'); }); it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'traversal-extension', version: '1.0.0', contextFileName: '../secret.txt', }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(0); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( 'traversal-extension: Invalid context file path: "../secret.txt"', ), ); consoleSpy.mockRestore(); }); it('should load context file path when GEMINI.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', addContextFile: true, }); createExtension({ extensionsDir: userExtensionsDir, name: 'ext2', version: '2.0.0', }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(2); const ext1 = extensions.find((e) => e.name === 'ext1'); const ext2 = extensions.find((e) => e.name === 'ext2'); expect(ext1?.contextFiles).toEqual([ path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), ]); expect(ext2?.contextFiles).toEqual([]); }); it('should load context file path from the extension config', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', addContextFile: false, contextFileName: 'my-context-file.md', }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const ext1 = extensions.find((e) => e.name === 'ext1'); expect(ext1?.contextFiles).toEqual([ path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), ]); }); it('should annotate disabled extensions', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'disabled-extension', version: '1.0.0', }); createExtension({ extensionsDir: userExtensionsDir, name: 'enabled-extension', version: '2.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension( 'disabled-extension', SettingScope.User, ); const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(2); expect(extensions[0].name).toBe('disabled-extension'); expect(extensions[0].isActive).toBe(false); expect(extensions[1].name).toBe('enabled-extension'); expect(extensions[1].isActive).toBe(true); }); it('should hydrate variables', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', addContextFile: false, contextFileName: undefined, mcpServers: { 'test-server': { cwd: '${extensionPath}${/}server', }, }, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const expectedCwd = path.join( userExtensionsDir, 'test-extension', 'server', ); expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd); }); it('should load a linked extension correctly', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempWorkspaceDir, name: 'my-linked-extension', version: '1.0.0', contextFileName: 'context.md', }), ); fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); await extensionManager.loadExtensions(); const extension = await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); expect(extension.name).toEqual('my-linked-extension'); const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(1); const linkedExt = extensions[0]; expect(linkedExt.name).toBe('my-linked-extension'); expect(linkedExt.path).toBe(sourceExtDir); expect(linkedExt.installMetadata).toEqual({ source: sourceExtDir, type: 'link', }); expect(linkedExt.contextFiles).toEqual([ path.join(sourceExtDir, 'context.md'), ]); }); it('should load extension policies from the policies directory', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'policy-extension', version: '1.0.0', }); const policiesDir = path.join(extDir, 'policies'); fs.mkdirSync(policiesDir); const policiesContent = ` [[rule]] toolName = "deny_tool" decision = "deny" priority = 500 [[rule]] toolName = "ask_tool" decision = "ask_user" priority = 100 `; fs.writeFileSync( path.join(policiesDir, 'policies.toml'), policiesContent, ); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; expect(extension.rules).toBeDefined(); expect(extension.rules).toHaveLength(2); expect( extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision, ).toBe('deny'); expect( extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision, ).toBe('ask_user'); // Verify source is prefixed expect(extension.rules![0].source).toContain( 'Extension (policy-extension):', ); }); it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'security-test-extension', version: '1.0.0', }); const policiesDir = path.join(extDir, 'policies'); fs.mkdirSync(policiesDir); const policiesContent = ` [[rule]] toolName = "allow_tool" decision = "allow" priority = 100 [[rule]] toolName = "yolo_tool" decision = "ask_user" priority = 100 modes = ["yolo"] [[safety_checker]] toolName = "yolo_check" priority = 100 modes = ["yolo"] [safety_checker.checker] type = "external" name = "yolo-checker" `; fs.writeFileSync( path.join(policiesDir, 'policies.toml'), policiesContent, ); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; // ALLOW rules and YOLO rules/checkers should be filtered out expect(extension.rules).toBeDefined(); expect(extension.rules).toHaveLength(0); expect(extension.checkers).toBeDefined(); expect(extension.checkers).toHaveLength(0); // Should have logged warnings expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('attempted to contribute an ALLOW rule'), ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('attempted to contribute a rule for YOLO mode'), ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( 'attempted to contribute a safety checker for YOLO mode', ), ); consoleSpy.mockRestore(); }); it('should hydrate ${extensionPath} correctly for linked extensions', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempWorkspaceDir, name: 'my-linked-extension-with-path', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['${extensionPath}${/}server${/}index.js'], cwd: '${extensionPath}${/}server', }, }, }), ); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers?.['test-server'].cwd).toBe( path.join(sourceExtDir, 'server'), ); expect(extensions[0].mcpServers?.['test-server'].args).toEqual([ path.join(sourceExtDir, 'server', 'index.js'), ]); }); it('should resolve environment variables in extension configuration', async () => { process.env['TEST_API_KEY'] = 'test-api-key-123'; process.env['TEST_DB_URL'] = 'postgresql://localhost:5432/testdb'; try { const userExtensionsDir = path.join( tempHomeDir, EXTENSIONS_DIRECTORY_NAME, ); fs.mkdirSync(userExtensionsDir, { recursive: true }); const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); // Write config to a separate file for clarity and good practices const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME); const extensionConfig = { name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], env: { API_KEY: '$TEST_API_KEY', DATABASE_URL: '${TEST_DB_URL}', STATIC_VALUE: 'no-substitution', }, }, }, }; fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; expect(extension.name).toBe('test-extension'); expect(extension.mcpServers).toBeDefined(); const serverConfig = extension.mcpServers?.['test-server']; expect(serverConfig).toBeDefined(); expect(serverConfig?.env).toBeDefined(); expect(serverConfig?.env?.['API_KEY']).toBe('test-api-key-123'); expect(serverConfig?.env?.['DATABASE_URL']).toBe( 'postgresql://localhost:5432/testdb', ); expect(serverConfig?.env?.['STATIC_VALUE']).toBe('no-substitution'); } finally { delete process.env['TEST_API_KEY']; delete process.env['TEST_DB_URL']; } }); it('should resolve environment variables from an extension .env file', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], env: { API_KEY: '$MY_API_KEY', STATIC_VALUE: 'no-substitution', }, }, }, settings: [ { name: 'My API Key', description: 'API key for testing.', envVar: 'MY_API_KEY', }, ], }); const envFilePath = path.join(extDir, '.env'); fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n'); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; const serverConfig = extension.mcpServers!['test-server']; expect(serverConfig.env).toBeDefined(); expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file'); expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution'); }); it('should handle missing environment variables gracefully', async () => { const userExtensionsDir = path.join( tempHomeDir, EXTENSIONS_DIRECTORY_NAME, ); fs.mkdirSync(userExtensionsDir, { recursive: true }); const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); const extensionConfig = { name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], env: { MISSING_VAR: '$UNDEFINED_ENV_VAR', MISSING_VAR_BRACES: '${ALSO_UNDEFINED}', }, }, }, }; fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), JSON.stringify(extensionConfig), ); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; const serverConfig = extension.mcpServers!['test-server']; expect(serverConfig.env).toBeDefined(); expect(serverConfig.env!['MISSING_VAR']).toBe('$UNDEFINED_ENV_VAR'); expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); it('should remove an extension with invalid JSON config and log a warning', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Good extension createExtension({ extensionsDir: userExtensionsDir, name: 'good-ext', version: '1.0.0', }); // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext'); fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`, ), ); consoleSpy.mockRestore(); }); it('should remove an extension with missing "name" in config and log a warning', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Good extension createExtension({ extensionsDir: userExtensionsDir, name: 'good-ext', version: '1.0.0', }); // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), ); consoleSpy.mockRestore(); }); it('should filter trust out of mcp servers', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], trust: true, }, }, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); it('should log a warning for invalid extension names during loading', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'bad_name', version: '1.0.0', }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'bad_name'); expect(extension).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid extension name: "bad_name"'), ); consoleSpy.mockRestore(); }); it('should not load github extensions and log a warning if blockGitExtensions is set', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', installMetadata: { type: 'git', source: 'http://somehost.com/foo/bar', }, }); const blockGitExtensionsSetting = createTestMergedSettings({ security: { blockGitExtensions: true }, }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( 'Extensions from remote sources is disallowed by your current settings.', ), ); consoleSpy.mockRestore(); }); it('should load allowed extensions if the allowlist is set.', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', installMetadata: { type: 'git', source: 'http://allowed.com/foo/bar', }, }); const extensionAllowlistSetting = createTestMergedSettings({ security: { allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], }, }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('my-ext'); }); it('should not load disallowed extensions and log a warning if the allowlist is set.', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', installMetadata: { type: 'git', source: 'http://notallowed.com/foo/bar', }, }); const extensionAllowlistSetting = createTestMergedSettings({ security: { allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], }, }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( 'This extension is not allowed by the "allowedExtensions" security setting', ), ); consoleSpy.mockRestore(); }); it('should not load any extensions if admin.extensions.enabled is false', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; loadedSettings.admin.extensions.enabled = false; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toEqual([]); }); it('should not load mcpServers if admin.mcp.enabled is false', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'echo', args: ['hello'] }, }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; loadedSettings.admin.mcp.enabled = false; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers).toBeUndefined(); }); it('should load mcpServers if admin.mcp.enabled is true', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'echo', args: ['hello'] }, }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; loadedSettings.admin.mcp.enabled = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers).toEqual({ 'test-server': { command: 'echo', args: ['hello'] }, }); }); describe('id generation', () => { it.each([ { description: 'should generate id from source for non-github git urls', installMetadata: { type: 'git' as const, source: 'http://somehost.com/foo/bar', }, expectedIdSource: 'http://somehost.com/foo/bar', }, { description: 'should generate id from owner/repo for github http urls', installMetadata: { type: 'git' as const, source: 'http://github.com/foo/bar', }, expectedIdSource: 'https://github.com/foo/bar', }, { description: 'should generate id from owner/repo for github ssh urls', installMetadata: { type: 'git' as const, source: 'git@github.com:foo/bar', }, expectedIdSource: 'https://github.com/foo/bar', }, { description: 'should generate id from source for github-release extension', installMetadata: { type: 'github-release' as const, source: 'https://github.com/foo/bar', }, expectedIdSource: 'https://github.com/foo/bar', }, { description: 'should generate id from the original source for local extension', installMetadata: { type: 'local' as const, source: '/some/path', }, expectedIdSource: '/some/path', }, ])('$description', async ({ installMetadata, expectedIdSource }) => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', installMetadata, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue(expectedIdSource)); }); it('should generate id from the original source for linked extensions', async () => { const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions'); const actualExtensionDir = getRealPath( createExtension({ extensionsDir: extDevelopmentDir, name: 'link-ext-name', version: '1.0.0', }), ); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ type: 'link', source: actualExtensionDir, }); const extension = extensionManager .getExtensions() .find((e) => e.name === 'link-ext-name'); expect(extension?.id).toBe(hashValue(actualExtensionDir)); }); it('should generate id from name for extension with no install metadata', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'no-meta-name', version: '1.0.0', }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'no-meta-name'); expect(extension?.id).toBe(hashValue('no-meta-name')); }); it('should load extension hooks and hydrate variables', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'hook-extension', version: '1.0.0', }); const hooksDir = path.join(extDir, 'hooks'); fs.mkdirSync(hooksDir); const hooksConfig = { enabled: false, hooks: { BeforeTool: [ { matcher: '.*', hooks: [ { type: 'command', command: 'echo ${extensionPath}', }, ], }, ], }, }; fs.writeFileSync( path.join(hooksDir, 'hooks.json'), JSON.stringify(hooksConfig), ); const settings = loadSettings(tempWorkspaceDir).merged; settings.hooksConfig.enabled = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; expect(extension.hooks).toBeDefined(); expect(extension.hooks?.BeforeTool).toHaveLength(1); expect(extension.hooks?.BeforeTool?.[0].hooks[0].command).toBe( `echo ${extDir}`, ); }); it('should not load hooks if hooks.enabled is false', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'hook-extension-disabled', version: '1.0.0', }); const hooksDir = path.join(extDir, 'hooks'); fs.mkdirSync(hooksDir); fs.writeFileSync( path.join(hooksDir, 'hooks.json'), JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }), ); const settings = loadSettings(tempWorkspaceDir).merged; settings.hooksConfig.enabled = false; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].hooks).toBeUndefined(); }); it('should warn about hooks during installation', async () => { const requestConsentSpy = vi.fn().mockResolvedValue(true); extensionManager.setRequestConsent(requestConsentSpy); const sourceExtDir = path.join( tempWorkspaceDir, 'hook-extension-source', ); fs.mkdirSync(sourceExtDir, { recursive: true }); const hooksDir = path.join(sourceExtDir, 'hooks'); fs.mkdirSync(hooksDir); fs.writeFileSync( path.join(hooksDir, 'hooks.json'), JSON.stringify({ hooks: {} }), ); fs.writeFileSync( path.join(sourceExtDir, 'gemini-extension.json'), JSON.stringify({ name: 'hook-extension-install', version: '1.0.0', }), ); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(requestConsentSpy).toHaveBeenCalledWith( expect.stringContaining('⚠️ This extension contains Hooks'), ); }); }); }); describe('installExtension', () => { it('should install an extension from a local path', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }), ); const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata).toEqual({ source: sourceExtDir, type: 'local', }); fs.rmSync(targetExtDir, { recursive: true, force: true }); }); it('should throw an error if the extension already exists', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow( 'Extension "my-local-extension" is already installed. Please uninstall it first.', ); }); it('should throw an error and cleanup if gemini-extension.json is missing', async () => { const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension')); fs.mkdirSync(sourceExtDir, { recursive: true }); const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow(`Configuration file not found at ${configPath}`); const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); expect(fs.existsSync(targetExtDir)).toBe(false); }); it('should throw an error for invalid JSON in gemini-extension.json', async () => { const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext')); fs.mkdirSync(sourceExtDir, { recursive: true }); const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow(`Failed to load extension config from ${configPath}`); }); it('should throw an error for missing name in gemini-extension.json', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempHomeDir, name: 'missing-name-ext', version: '1.0.0', }), ); const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); // Overwrite with invalid config fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow( `Invalid configuration in ${configPath}: missing "name"`, ); }); it('should install an extension from a git URL', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; const extensionName = 'some-extension'; const targetExtDir = path.join(userExtensionsDir, extensionName); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); mockGit.clone.mockImplementation(async (_, destination) => { fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true, }); fs.writeFileSync( path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), JSON.stringify({ name: extensionName, version: '1.0.0' }), ); }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); mockDownloadFromGithubRelease.mockResolvedValue({ success: false, failureReason: 'no release data', type: 'github-release', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata).toEqual({ source: gitUrl, type: 'git', }); }); it('should install a linked extension', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempHomeDir, name: 'my-linked-extension', version: '1.0.0', }), ); const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); expect(fs.existsSync(configPath)).toBe(false); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata).toEqual({ source: sourceExtDir, type: 'link', }); fs.rmSync(targetExtDir, { recursive: true, force: true }); }); it('should not install a github extension if blockGitExtensions is set', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; const blockGitExtensionsSetting = createTestMergedSettings({ security: { blockGitExtensions: true }, }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', }), ).rejects.toThrow( 'Installing extensions from remote sources is disallowed by your current settings.', ); }); it('should not install a disallowed extension if the allowlist is set', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; const allowedExtensionsSetting = createTestMergedSettings({ security: { allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], }, }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: allowedExtensionsSetting, integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', }), ).rejects.toThrow( `Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`, ); }); it('should prompt for trust if workspace is not trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: undefined, }); const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(mockRequestConsent).toHaveBeenCalledWith( `The current workspace at "${tempWorkspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, ); }); it('should not install if user denies trust', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: undefined, }); mockRequestConsent.mockImplementation(async (message) => { if ( message.includes( 'is not trusted. Do you want to trust this workspace to install extensions?', ) ) { return false; } return true; }); const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow( `Could not install extension because the current workspace at ${tempWorkspaceDir} is not trusted.`, ); }); it('should add the workspace to trusted folders if user consents', async () => { const trustedFoldersPath = path.join( tempHomeDir, '.gemini', 'trustedFolders.json', ); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: undefined, }); const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(fs.existsSync(trustedFoldersPath)).toBe(true); const trustedFolders = JSON.parse( fs.readFileSync(trustedFoldersPath, 'utf-8'), ); expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER'); }); describe.each([true, false])( 'with previous extension config: %s', (isUpdate: boolean) => { let sourceExtDir: string; beforeEach(async () => { sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.1.0', }); await extensionManager.loadExtensions(); if (isUpdate) { await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); } // Clears out any calls to mocks from the above function calls. vi.clearAllMocks(); }); it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => { await extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, isUpdate ? { name: 'my-local-extension', version: '1.0.0', } : undefined, ); if (isUpdate) { expect(mockLogExtensionUpdateEvent).toHaveBeenCalled(); expect(mockLogExtensionInstallEvent).not.toHaveBeenCalled(); } else { expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); expect(mockLogExtensionUpdateEvent).not.toHaveBeenCalled(); } }); it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('my-local-extension', true, '/some/scope'); await extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, isUpdate ? { name: 'my-local-extension', version: '1.0.0', } : undefined, ); const config = enablementManager.readConfig()['my-local-extension']; if (isUpdate) { expect(config).not.toBeUndefined(); expect(config.overrides).toContain('/some/scope/*'); } else { expect(config).not.toContain('/some/scope/*'); } }); }, ); it('should show users information on their ansi escaped mcp servers when installing', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node dobadthing \u001b[12D\u001b[K', args: ['server.js'], description: 'a local mcp server', }, 'test-server-2': { description: 'a remote mcp server', httpUrl: 'https://google.com', }, }, }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).resolves.toMatchObject({ name: 'my-local-extension', }); expect(mockRequestConsent).toHaveBeenCalledWith( `Installing extension "my-local-extension". This extension will run the following MCP servers: * test-server (local): node dobadthing \\u001b[12D\\u001b[K server.js * test-server-2 (remote): https://google.com ${INSTALL_WARNING_MESSAGE}`, ); }); it('should continue installation if user accepts prompt for local extension with mcp servers', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], }, }, }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).resolves.toMatchObject({ name: 'my-local-extension' }); }); it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], }, }, }); mockRequestConsent.mockResolvedValue(false); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow('Installation cancelled for "my-local-extension".'); }); it('should save the autoUpdate flag to the install metadata', async () => { const sourceExtDir = getRealPath( createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', }), ); const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', autoUpdate: true, }); expect(fs.existsSync(targetExtDir)).toBe(true); expect(fs.existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata).toEqual({ source: sourceExtDir, type: 'local', autoUpdate: true, }); fs.rmSync(targetExtDir, { recursive: true, force: true }); }); it('should ignore consent flow if not required', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', mcpServers: { 'test-server': { command: 'node', args: ['server.js'], }, }, }); await extensionManager.loadExtensions(); // Install it with hard coded consent first. await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(mockRequestConsent).toHaveBeenCalledOnce(); // Now update it without changing anything. await expect( extensionManager.installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, // Provide its own existing config as the previous config. await extensionManager.loadExtensionConfig(sourceExtDir), ), ).resolves.toMatchObject({ name: 'my-local-extension' }); // Still only called once expect(mockRequestConsent).toHaveBeenCalledOnce(); }); it('should prompt for settings if promptForSettings', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', settings: [ { name: 'API Key', description: 'Your API key for the service.', envVar: 'MY_API_KEY', }, ], }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); expect(mockPromptForSettings).toHaveBeenCalled(); }); it('should not prompt for settings if promptForSettings is false', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', version: '1.0.0', settings: [ { name: 'API Key', description: 'Your API key for the service.', envVar: 'MY_API_KEY', }, ], }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: null, settings: loadSettings(tempWorkspaceDir).merged, integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }); }); it('should only prompt for new settings on update, and preserve old settings', async () => { // 1. Create and install the "old" version of the extension. const oldSourceExtDir = createExtension({ extensionsDir: tempHomeDir, // Create it in a temp location first name: 'my-local-extension', version: '1.0.0', settings: [ { name: 'API Key', description: 'Your API key for the service.', envVar: 'MY_API_KEY', }, ], }); mockPromptForSettings.mockResolvedValueOnce('old-api-key'); await extensionManager.loadExtensions(); // Install it so it exists in the userExtensionsDir await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, type: 'local', }); const envPath = new ExtensionStorage( 'my-local-extension', ).getEnvFilePath(); expect(fs.existsSync(envPath)).toBe(true); let envContent = fs.readFileSync(envPath, 'utf-8'); expect(envContent).toContain('MY_API_KEY=old-api-key'); expect(mockPromptForSettings).toHaveBeenCalledTimes(1); // 2. Create the "new" version of the extension in a new source directory. const newSourceExtDir = createExtension({ extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location name: 'my-local-extension', // Same name version: '1.1.0', // New version settings: [ { name: 'API Key', description: 'Your API key for the service.', envVar: 'MY_API_KEY', }, { name: 'New Setting', description: 'A new setting.', envVar: 'NEW_SETTING', }, ], }); const previousExtensionConfig = await extensionManager.loadExtensionConfig( path.join(userExtensionsDir, 'my-local-extension'), ); mockPromptForSettings.mockResolvedValueOnce('new-setting-value'); // 3. Call installOrUpdateExtension to perform the update. await extensionManager.installOrUpdateExtension( { source: newSourceExtDir, type: 'local' }, previousExtensionConfig, ); expect(mockPromptForSettings).toHaveBeenCalledTimes(2); expect(mockPromptForSettings).toHaveBeenCalledWith( expect.objectContaining({ name: 'New Setting' }), ); expect(fs.existsSync(envPath)).toBe(true); envContent = fs.readFileSync(envPath, 'utf-8'); expect(envContent).toContain('MY_API_KEY=old-api-key'); expect(envContent).toContain('NEW_SETTING=new-setting-value'); }); it('should auto-update if settings have changed', async () => { // 1. Install initial version with autoUpdate: true const oldSourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-auto-update-ext', version: '1.0.0', settings: [ { name: 'OLD_SETTING', envVar: 'OLD_SETTING', description: 'An old setting', }, ], }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, type: 'local', autoUpdate: true, }); // 2. Create new version with different settings const extensionDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-auto-update-ext', version: '1.1.0', settings: [ { name: 'NEW_SETTING', envVar: 'NEW_SETTING', description: 'A new setting', }, ], }); const previousExtensionConfig = await extensionManager.loadExtensionConfig( path.join(userExtensionsDir, 'my-auto-update-ext'), ); // 3. Attempt to update and assert it fails const updatedExtension = await extensionManager.installOrUpdateExtension( { source: extensionDir, type: 'local', autoUpdate: true, }, previousExtensionConfig, ); expect(updatedExtension.version).toBe('1.1.0'); expect(extensionManager.getExtensions()[0].version).toBe('1.1.0'); }); it('should throw an error for invalid extension names', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'bad_name', version: '1.0.0', }); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), ).rejects.toThrow('Invalid extension name: "bad_name"'); }); describe('installing from github', () => { const gitUrl = 'https://github.com/google/gemini-test-extension.git'; const extensionName = 'gemini-test-extension'; beforeEach(() => { // Mock the git clone behavior for github installs that fallback to it. mockGit.clone.mockImplementation(async (_, destination) => { fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true, }); fs.writeFileSync( path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), JSON.stringify({ name: extensionName, version: '1.0.0' }), ); }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); }); afterEach(() => { vi.restoreAllMocks(); }); it('should install from a github release successfully', async () => { const targetExtDir = path.join(userExtensionsDir, extensionName); mockDownloadFromGithubRelease.mockResolvedValue({ success: true, tagName: 'v1.0.0', type: 'github-release', }); const tempDir = path.join(tempHomeDir, 'temp-ext'); fs.mkdirSync(tempDir, { recursive: true }); createExtension({ extensionsDir: tempDir, name: extensionName, version: '1.0.0', }); vi.spyOn(ExtensionStorage, 'createTmpDir').mockResolvedValue( join(tempDir, extensionName), ); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release', }); expect(fs.existsSync(targetExtDir)).toBe(true); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); expect(fs.existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata).toEqual({ source: gitUrl, type: 'github-release', releaseTag: 'v1.0.0', }); }); it('should fallback to git clone if github release download fails and user consents', async () => { mockDownloadFromGithubRelease.mockResolvedValue({ success: false, failureReason: 'failed to download asset', errorMessage: 'download failed', type: 'github-release', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Use github-release to force consent ); // It gets called once to ask for a git clone, and once to consent to // the actual extension features. expect(mockRequestConsent).toHaveBeenCalledTimes(2); expect(mockRequestConsent).toHaveBeenCalledWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), ); expect(mockGit.clone).toHaveBeenCalled(); const metadataPath = path.join( userExtensionsDir, extensionName, INSTALL_METADATA_FILENAME, ); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata.type).toBe('git'); }); it('should throw an error if github release download fails and user denies consent', async () => { mockDownloadFromGithubRelease.mockResolvedValue({ success: false, errorMessage: 'download failed', type: 'github-release', }); mockRequestConsent.mockResolvedValue(false); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release', }), ).rejects.toThrow( `Failed to install extension ${gitUrl}: download failed`, ); expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), ); expect(mockGit.clone).not.toHaveBeenCalled(); }); it('should fallback to git clone without consent if no release data is found on first install', async () => { mockDownloadFromGithubRelease.mockResolvedValue({ success: false, failureReason: 'no release data', type: 'github-release', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', }); // We should not see the request to use git clone, this is a repo that // has no github releases so it is the only install method. expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( 'Installing extension "gemini-test-extension"', ), ); expect(mockGit.clone).toHaveBeenCalled(); const metadataPath = path.join( userExtensionsDir, extensionName, INSTALL_METADATA_FILENAME, ); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); expect(metadata.type).toBe('git'); }); it('should ask for consent if no release data is found for an existing github-release extension', async () => { mockDownloadFromGithubRelease.mockResolvedValue({ success: false, failureReason: 'no release data', errorMessage: 'No release data found', type: 'github-release', }); await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Note the type ); expect(mockRequestConsent).toHaveBeenCalledWith( expect.stringContaining( 'Would you like to attempt to install via "git clone" instead?', ), ); expect(mockGit.clone).toHaveBeenCalled(); }); }); }); describe('uninstallExtension', () => { it('should uninstall an extension by name', async () => { const sourceExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'my-local-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); }); it('should uninstall an extension by name and retain existing extensions', async () => { const sourceExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'my-local-extension', version: '1.0.0', }); const otherExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'other-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect(extensionManager.getExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); it('should uninstall an extension on non-matching extension directory name', async () => { // Create an extension with a name that differs from the directory name. const sourceExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'My-Local-Extension', version: '1.0.0', }); const newSourceExtDir = path.join( userExtensionsDir, 'my-local-extension', ); fs.renameSync(sourceExtDir, newSourceExtDir); const otherExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'other-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect(fs.existsSync(newSourceExtDir)).toBe(false); expect(extensionManager.getExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); it('should throw an error if the extension does not exist', async () => { await extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension('nonexistent-extension', false), ).rejects.toThrow('Extension not found.'); }); describe.each([true, false])('with isUpdate: %s', (isUpdate: boolean) => { it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-local-extension', version: '1.0.0', installMetadata: { source: userExtensionsDir, type: 'local', }, }); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension( 'my-local-extension', isUpdate, ); if (isUpdate) { expect(mockLogExtensionUninstall).not.toHaveBeenCalled(); expect(ExtensionUninstallEvent).not.toHaveBeenCalled(); } else { expect(mockLogExtensionUninstall).toHaveBeenCalled(); expect(ExtensionUninstallEvent).toHaveBeenCalledWith( 'my-local-extension', hashValue('my-local-extension'), hashValue(userExtensionsDir), 'success', ); } }); it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', }); const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('test-extension', true, '/some/scope'); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('test-extension', isUpdate); const config = enablementManager.readConfig()['test-extension']; if (isUpdate) { expect(config).not.toBeUndefined(); expect(config.overrides).toEqual(['/some/scope/*']); } else { expect(config).toBeUndefined(); } }); }); it('should uninstall an extension by its source URL', async () => { const gitUrl = 'https://github.com/google/gemini-sql-extension.git'; const sourceExtDir = createExtension({ extensionsDir: userExtensionsDir, name: 'gemini-sql-extension', version: '1.0.0', installMetadata: { source: gitUrl, type: 'git', }, }); await extensionManager.loadExtensions(); await extensionManager.uninstallExtension(gitUrl, false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect(mockLogExtensionUninstall).toHaveBeenCalled(); expect(ExtensionUninstallEvent).toHaveBeenCalledWith( 'gemini-sql-extension', hashValue('gemini-sql-extension'), hashValue('https://github.com/google/gemini-sql-extension'), 'success', ); }); it('should fail to uninstall by URL if an extension has no install metadata', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'no-metadata-extension', version: '1.0.0', // No installMetadata provided }); await extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension( 'https://github.com/google/no-metadata-extension', false, ), ).rejects.toThrow('Extension not found.'); }); }); describe('disableExtension', () => { it('should disable an extension at the user scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension( 'my-extension', SettingScope.User, ); expect( isEnabled({ name: 'my-extension', enabledForPath: tempWorkspaceDir, }), ).toBe(false); }); it('should disable an extension at the workspace scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension( 'my-extension', SettingScope.Workspace, ); expect( isEnabled({ name: 'my-extension', enabledForPath: tempHomeDir, }), ).toBe(true); expect( isEnabled({ name: 'my-extension', enabledForPath: tempWorkspaceDir, }), ).toBe(false); }); it('should handle disabling the same extension twice', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension( 'my-extension', SettingScope.User, ); await extensionManager.disableExtension( 'my-extension', SettingScope.User, ); expect( isEnabled({ name: 'my-extension', enabledForPath: tempWorkspaceDir, }), ).toBe(false); }); it('should throw an error if you request system scope', async () => { await expect(async () => extensionManager.disableExtension('my-extension', SettingScope.System), ).rejects.toThrow('System and SystemDefaults scopes are not supported.'); }); it('should log a disable event', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', installMetadata: { source: userExtensionsDir, type: 'local', }, }); await extensionManager.loadExtensions(); await extensionManager.disableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionDisable).toHaveBeenCalled(); expect(ExtensionDisableEvent).toHaveBeenCalledWith( 'ext1', hashValue('ext1'), hashValue(userExtensionsDir), SettingScope.Workspace, ); }); }); describe('enableExtension', () => { afterAll(() => { vi.restoreAllMocks(); }); const getActiveExtensions = (): GeminiCLIExtension[] => { const extensions = extensionManager.getExtensions(); return extensions.filter((e) => e.isActive); }; it('should enable an extension at the user scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension('ext1', SettingScope.User); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); await extensionManager.enableExtension('ext1', SettingScope.User); activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); }); it('should enable an extension at the workspace scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); await extensionManager.loadExtensions(); await extensionManager.disableExtension('ext1', SettingScope.Workspace); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); await extensionManager.enableExtension('ext1', SettingScope.Workspace); activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); }); it('should log an enable event', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', installMetadata: { source: userExtensionsDir, type: 'local', }, }); await extensionManager.loadExtensions(); await extensionManager.disableExtension('ext1', SettingScope.Workspace); await extensionManager.enableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionEnable).toHaveBeenCalled(); expect(ExtensionEnableEvent).toHaveBeenCalledWith( 'ext1', hashValue('ext1'), hashValue(userExtensionsDir), SettingScope.Workspace, ); }); }); }); function isEnabled(options: { name: string; enabledForPath: string }) { const manager = new ExtensionEnablementManager(); return manager.isEnabled(options.name, options.enabledForPath); } ================================================ FILE: packages/cli/src/config/extension.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { MCPServerConfig, ExtensionInstallMetadata, CustomTheme, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { INSTALL_METADATA_FILENAME } from './extensions/variables.js'; import type { ExtensionSetting } from './extensions/extensionSettings.js'; /** * Extension definition as written to disk in gemini-extension.json files. * This should *not* be referenced outside of the logic for reading files. * If information is required for manipulating extensions (load, unload, update) * outside of the loading process that data needs to be stored on the * GeminiCLIExtension class defined in Core. */ export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; /** * Custom themes contributed by this extension. * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; /** * Planning features configuration contributed by this extension. */ plan?: { /** * The directory where planning artifacts are stored. */ directory?: string; }; /** * Used to migrate an extension to a new repository source. */ migratedTo?: string; } export interface ExtensionUpdateInfo { name: string; originalVersion: string; updatedVersion: string; } export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); try { const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; return metadata; } catch (_e) { return undefined; } } ================================================ FILE: packages/cli/src/config/extensionRegistryClient.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import * as fs from 'node:fs/promises'; import { ExtensionRegistryClient, type RegistryExtension, } from './extensionRegistryClient.js'; import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchWithTimeout: vi.fn(), }; }); vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), })); const mockExtensions: RegistryExtension[] = [ { id: 'ext1', rank: 1, url: 'https://github.com/test/ext1', fullName: 'test/ext1', repoDescription: 'Test extension 1', stars: 100, lastUpdated: '2025-01-01T00:00:00Z', extensionName: 'extension-one', extensionVersion: '1.0.0', extensionDescription: 'First test extension', avatarUrl: 'https://example.com/avatar1.png', hasMCP: true, hasContext: false, isGoogleOwned: false, licenseKey: 'mit', hasHooks: false, hasCustomCommands: false, hasSkills: false, }, { id: 'ext2', rank: 2, url: 'https://github.com/test/ext2', fullName: 'test/ext2', repoDescription: 'Test extension 2', stars: 50, lastUpdated: '2025-01-02T00:00:00Z', extensionName: 'extension-two', extensionVersion: '0.5.0', extensionDescription: 'Second test extension', avatarUrl: 'https://example.com/avatar2.png', hasMCP: false, hasContext: true, isGoogleOwned: true, licenseKey: 'apache-2.0', hasHooks: false, hasCustomCommands: false, hasSkills: false, }, { id: 'ext3', rank: 3, url: 'https://github.com/test/ext3', fullName: 'test/ext3', repoDescription: 'Test extension 3', stars: 10, lastUpdated: '2025-01-03T00:00:00Z', extensionName: 'extension-three', extensionVersion: '0.1.0', extensionDescription: 'Third test extension', avatarUrl: 'https://example.com/avatar3.png', hasMCP: true, hasContext: true, isGoogleOwned: false, licenseKey: 'gpl-3.0', hasHooks: false, hasCustomCommands: false, hasSkills: false, }, ]; describe('ExtensionRegistryClient', () => { let client: ExtensionRegistryClient; let fetchMock: Mock; beforeEach(() => { ExtensionRegistryClient.resetCache(); client = new ExtensionRegistryClient(); fetchMock = fetchWithTimeout as Mock; fetchMock.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should fetch and return extensions with pagination (default ranking)', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const result = await client.getExtensions(1, 2); expect(result.extensions).toHaveLength(2); expect(result.extensions[0].id).toBe('ext1'); // rank 1 expect(result.extensions[1].id).toBe('ext2'); // rank 2 expect(result.total).toBe(3); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( 'https://geminicli.com/extensions.json', 10000, ); }); it('should return extensions sorted alphabetically', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const result = await client.getExtensions(1, 3, 'alphabetical'); expect(result.extensions).toHaveLength(3); expect(result.extensions[0].id).toBe('ext1'); expect(result.extensions[1].id).toBe('ext3'); expect(result.extensions[2].id).toBe('ext2'); }); it('should return the second page of extensions', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const result = await client.getExtensions(2, 2); expect(result.extensions).toHaveLength(1); expect(result.extensions[0].id).toBe('ext3'); expect(result.total).toBe(3); }); it('should search extensions by name', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const results = await client.searchExtensions('one'); expect(results.length).toBeGreaterThanOrEqual(1); expect(results[0].id).toBe('ext1'); }); it('should search extensions by description', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const results = await client.searchExtensions('Second'); expect(results.length).toBeGreaterThanOrEqual(1); expect(results[0].id).toBe('ext2'); }); it('should get an extension by ID', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const result = await client.getExtension('ext2'); expect(result).toBeDefined(); expect(result?.id).toBe('ext2'); }); it('should return undefined if extension not found', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const result = await client.getExtension('non-existent'); expect(result).toBeUndefined(); }); it('should cache the fetch result', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); await client.getExtensions(); await client.getExtensions(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should share the fetch result across instances', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => mockExtensions, }); const client1 = new ExtensionRegistryClient(); const client2 = new ExtensionRegistryClient(); await client1.getExtensions(); await client2.getExtensions(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should throw an error if fetch fails', async () => { fetchMock.mockResolvedValue({ ok: false, statusText: 'Not Found', }); await expect(client.getExtensions()).rejects.toThrow( 'Failed to fetch extensions: Not Found', ); }); it('should not return irrelevant results', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => [ ...mockExtensions, { id: 'dataplex', extensionName: 'dataplex', extensionDescription: 'Connect to Dataplex Universal Catalog...', fullName: 'google-cloud/dataplex', rank: 6, stars: 6, url: '', repoDescription: '', lastUpdated: '', extensionVersion: '1.0.0', avatarUrl: '', hasMCP: false, hasContext: false, isGoogleOwned: true, licenseKey: '', hasHooks: false, hasCustomCommands: false, hasSkills: false, }, { id: 'conductor', extensionName: 'conductor', extensionDescription: 'A conductor extension that actually matches.', fullName: 'someone/conductor', rank: 100, stars: 100, url: '', repoDescription: '', lastUpdated: '', extensionVersion: '1.0.0', avatarUrl: '', hasMCP: false, hasContext: false, isGoogleOwned: false, licenseKey: '', hasHooks: false, hasCustomCommands: false, hasSkills: false, }, ], }); const results = await client.searchExtensions('conductor'); const ids = results.map((r) => r.id); expect(ids).not.toContain('dataplex'); expect(ids).toContain('conductor'); }); it('should fetch extensions from a local file path', async () => { const filePath = '/path/to/extensions.json'; const clientWithFile = new ExtensionRegistryClient(filePath); const mockReadFile = vi.mocked(fs.readFile); mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); const result = await clientWithFile.getExtensions(); expect(result.extensions).toHaveLength(3); expect(mockReadFile).toHaveBeenCalledWith( resolveToRealPath(filePath), 'utf-8', ); }); it('should fetch extensions from a file:// URL', async () => { const fileUrl = 'file:///path/to/extensions.json'; const clientWithFileUrl = new ExtensionRegistryClient(fileUrl); const mockReadFile = vi.mocked(fs.readFile); mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); const result = await clientWithFileUrl.getExtensions(); expect(result.extensions).toHaveLength(3); expect(mockReadFile).toHaveBeenCalledWith( resolveToRealPath(fileUrl), 'utf-8', ); }); }); ================================================ FILE: packages/cli/src/config/extensionRegistryClient.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import { fetchWithTimeout, resolveToRealPath, isPrivateIp, } from '@google/gemini-cli-core'; import { AsyncFzf } from 'fzf'; export interface RegistryExtension { id: string; rank: number; url: string; fullName: string; repoDescription: string; stars: number; lastUpdated: string; extensionName: string; extensionVersion: string; extensionDescription: string; avatarUrl: string; hasMCP: boolean; hasContext: boolean; hasHooks: boolean; hasSkills: boolean; hasCustomCommands: boolean; isGoogleOwned: boolean; licenseKey: string; } export class ExtensionRegistryClient { static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/extensions.json'; private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds private static fetchPromise: Promise | null = null; private readonly registryURI: string; constructor(registryURI?: string) { this.registryURI = registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL; } /** @internal */ static resetCache() { ExtensionRegistryClient.fetchPromise = null; } async getExtensions( page: number = 1, limit: number = 10, orderBy: 'ranking' | 'alphabetical' = 'ranking', ): Promise<{ extensions: RegistryExtension[]; total: number }> { const allExtensions = [...(await this.fetchAllExtensions())]; switch (orderBy) { case 'ranking': allExtensions.sort((a, b) => a.rank - b.rank); break; case 'alphabetical': allExtensions.sort((a, b) => a.extensionName.localeCompare(b.extensionName), ); break; default: { const _exhaustiveCheck: never = orderBy; throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); } } const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; return { extensions: allExtensions.slice(startIndex, endIndex), total: allExtensions.length, }; } async searchExtensions(query: string): Promise { const allExtensions = await this.fetchAllExtensions(); if (!query.trim()) { return allExtensions; } const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, fuzzy: true, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(query); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: RegistryExtension }) => r.item); } async getExtension(id: string): Promise { const allExtensions = await this.fetchAllExtensions(); return allExtensions.find((ext) => ext.id === id); } private async fetchAllExtensions(): Promise { if (ExtensionRegistryClient.fetchPromise) { return ExtensionRegistryClient.fetchPromise; } const uri = this.registryURI; ExtensionRegistryClient.fetchPromise = (async () => { try { if (uri.startsWith('http')) { if (isPrivateIp(uri)) { throw new Error( 'Private IP addresses are not allowed for the extension registry.', ); } const response = await fetchWithTimeout( uri, ExtensionRegistryClient.FETCH_TIMEOUT_MS, ); if (!response.ok) { throw new Error( `Failed to fetch extensions: ${response.statusText}`, ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return (await response.json()) as RegistryExtension[]; } else { // Handle local file path const filePath = resolveToRealPath(uri); const content = await fs.readFile(filePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as RegistryExtension[]; } } catch (error) { ExtensionRegistryClient.fetchPromise = null; throw error; } })(); return ExtensionRegistryClient.fetchPromise; } } ================================================ FILE: packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`consent > maybeRequestConsentOrFail > consent string generation > should generate a consent string with all fields 1`] = ` "Installing extension "test-ext". This extension will run the following MCP servers: * server1 (local): npm start * server2 (remote): https://remote.com This extension will append info to your gemini.md context using my-context.md This extension will exclude the following core tools: tool1,tool2 The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform." `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = ` "Installing extension "test-ext". ⚠️ This extension contains Hooks which can automatically execute commands. The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform." `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = ` "Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform." `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` "Installing extension "test-ext". This extension will run the following MCP servers: * server1 (local): npm start * server2 (remote): https://remote.com This extension will append info to your gemini.md context using my-context.md This extension will exclude the following core tools: tool1,tool2 Agent Skills: This extension will install the following agent skills: * skill1: desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) * skill2: desc2 (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform. Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards." `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = ` "Installing extension "test-ext". Agent Skills: This extension will install the following agent skills: * locked-skill: A skill in a locked dir (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory) The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform. Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards." `; exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = ` "Installing agent skill(s) from "https://example.com/repo.git". The following agent skill(s) will be installing: * skill1: desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) Install Destination: /mock/target/dir Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards." `; ================================================ FILE: packages/cli/src/config/extensions/consent.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Text } from 'ink'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { render, cleanup } from '../../test-utils/render.js'; import { requestConsentNonInteractive, requestConsentInteractive, maybeRequestConsentOrFail, } from './consent.js'; import type { ConfirmationRequest } from '../../ui/types.js'; import type { ExtensionConfig } from '../extension.js'; import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; const mockReadline = vi.hoisted(() => ({ createInterface: vi.fn().mockReturnValue({ question: vi.fn(), close: vi.fn(), }), })); const mockReaddir = vi.hoisted(() => vi.fn()); const originalReaddir = vi.hoisted(() => ({ current: null as typeof fs.readdir | null, })); // Mocking readline for non-interactive prompts vi.mock('node:readline', () => ({ default: mockReadline, createInterface: mockReadline.createInterface, })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); originalReaddir.current = actual.readdir; return { ...actual, readdir: mockReaddir, }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger: { log: vi.fn(), }, }; }); async function expectConsentSnapshot(consentString: string) { const renderResult = render(React.createElement(Text, null, consentString)); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); } /** * Normalizes a consent string for snapshot testing by: * 1. Replacing the dynamic temp directory path with a static placeholder. * 2. Converting Windows backslashes to forward slashes for platform-agnosticism. */ function normalizePathsForSnapshot(str: string, tempDir: string): string { return str.replaceAll(tempDir, '/mock/temp/dir').replaceAll('\\', '/'); } describe('consent', () => { let tempDir: string; beforeEach(async () => { vi.clearAllMocks(); if (originalReaddir.current) { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockReaddir.mockImplementation(originalReaddir.current as any); } tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-')); }); afterEach(async () => { vi.restoreAllMocks(); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } cleanup(); }); describe('requestConsentNonInteractive', () => { it.each([ { input: 'y', expected: true }, { input: 'Y', expected: true }, { input: '', expected: true }, { input: 'n', expected: false }, { input: 'N', expected: false }, { input: 'yes', expected: true }, ])( 'should return $expected for input "$input"', async ({ input, expected }) => { const questionMock = vi.fn().mockImplementation((_, callback) => { callback(input); }); mockReadline.createInterface.mockReturnValue({ question: questionMock, close: vi.fn(), }); const consent = await requestConsentNonInteractive('Test consent'); expect(debugLogger.log).toHaveBeenCalledWith('Test consent'); expect(questionMock).toHaveBeenCalledWith( 'Do you want to continue? [Y/n]: ', expect.any(Function), ); expect(consent).toBe(expected); }, ); }); describe('requestConsentInteractive', () => { it.each([ { confirmed: true, expected: true }, { confirmed: false, expected: false }, ])( 'should resolve with $expected when user confirms with $confirmed', async ({ confirmed, expected }) => { const addExtensionUpdateConfirmationRequest = vi .fn() .mockImplementation((request: ConfirmationRequest) => { request.onConfirm(confirmed); }); const consent = await requestConsentInteractive( 'Test consent', addExtensionUpdateConfirmationRequest, ); expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({ prompt: 'Test consent\n\nDo you want to continue?', onConfirm: expect.any(Function), }); expect(consent).toBe(expected); }, ); }); describe('maybeRequestConsentOrFail', () => { const baseConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', }; it('should request consent if there is no previous config', async () => { const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, false, undefined, ); expect(requestConsent).toHaveBeenCalledTimes(1); }); it('should not request consent if configs are identical', async () => { const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, false, baseConfig, false, ); expect(requestConsent).not.toHaveBeenCalled(); }); it('should throw an error if consent is denied', async () => { const requestConsent = vi.fn().mockResolvedValue(false); await expect( maybeRequestConsentOrFail(baseConfig, requestConsent, false, undefined), ).rejects.toThrow('Installation cancelled for "test-ext".'); }); describe('consent string generation', () => { it('should generate a consent string with all fields', async () => { const config: ExtensionConfig = { ...baseConfig, mcpServers: { server1: { command: 'npm', args: ['start'] }, server2: { httpUrl: 'https://remote.com' }, }, contextFileName: 'my-context.md', excludeTools: ['tool1', 'tool2'], }; const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( config, requestConsent, false, undefined, ); expect(requestConsent).toHaveBeenCalledTimes(1); const consentString = requestConsent.mock.calls[0][0] as string; await expectConsentSnapshot(consentString); }); it('should request consent if mcpServers change', async () => { const prevConfig: ExtensionConfig = { ...baseConfig }; const newConfig: ExtensionConfig = { ...baseConfig, mcpServers: { server1: { command: 'npm', args: ['start'] } }, }; const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( newConfig, requestConsent, false, prevConfig, false, ); expect(requestConsent).toHaveBeenCalledTimes(1); }); it('should request consent if contextFileName changes', async () => { const prevConfig: ExtensionConfig = { ...baseConfig }; const newConfig: ExtensionConfig = { ...baseConfig, contextFileName: 'new-context.md', }; const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( newConfig, requestConsent, false, prevConfig, false, ); expect(requestConsent).toHaveBeenCalledTimes(1); }); it('should request consent if excludeTools changes', async () => { const prevConfig: ExtensionConfig = { ...baseConfig }; const newConfig: ExtensionConfig = { ...baseConfig, excludeTools: ['new-tool'], }; const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( newConfig, requestConsent, false, prevConfig, false, ); expect(requestConsent).toHaveBeenCalledTimes(1); }); it('should include warning when hooks are present', async () => { const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, true, undefined, ); expect(requestConsent).toHaveBeenCalledTimes(1); const consentString = requestConsent.mock.calls[0][0] as string; await expectConsentSnapshot(consentString); }); it('should request consent if hooks status changes', async () => { const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, true, baseConfig, false, ); expect(requestConsent).toHaveBeenCalledTimes(1); }); it('should request consent if extension is migrated', async () => { const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, false, { ...baseConfig, name: 'old-ext' }, false, [], [], true, ); expect(requestConsent).toHaveBeenCalledTimes(1); let consentString = requestConsent.mock.calls[0][0] as string; consentString = normalizePathsForSnapshot(consentString, tempDir); await expectConsentSnapshot(consentString); }); it('should request consent if skills change', async () => { const skill1Dir = path.join(tempDir, 'skill1'); const skill2Dir = path.join(tempDir, 'skill2'); await fs.mkdir(skill1Dir, { recursive: true }); await fs.mkdir(skill2Dir, { recursive: true }); await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra'); await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2'); const skill1: SkillDefinition = { name: 'skill1', description: 'desc1', location: path.join(skill1Dir, 'SKILL.md'), body: 'body1', }; const skill2: SkillDefinition = { name: 'skill2', description: 'desc2', location: path.join(skill2Dir, 'SKILL.md'), body: 'body2', }; const config: ExtensionConfig = { ...baseConfig, mcpServers: { server1: { command: 'npm', args: ['start'] }, server2: { httpUrl: 'https://remote.com' }, }, contextFileName: 'my-context.md', excludeTools: ['tool1', 'tool2'], }; const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( config, requestConsent, false, undefined, false, [skill1, skill2], ); expect(requestConsent).toHaveBeenCalledTimes(1); let consentString = requestConsent.mock.calls[0][0] as string; consentString = normalizePathsForSnapshot(consentString, tempDir); await expectConsentSnapshot(consentString); }); it('should show a warning if the skill directory cannot be read', async () => { const lockedDir = path.join(tempDir, 'locked'); await fs.mkdir(lockedDir, { recursive: true }); const skill: SkillDefinition = { name: 'locked-skill', description: 'A skill in a locked dir', location: path.join(lockedDir, 'SKILL.md'), body: 'body', }; // Mock readdir to simulate a permission error. // We do this instead of using fs.mkdir(..., { mode: 0o000 }) because // directory permissions work differently on Windows and 0o000 doesn't // effectively block access there, leading to test failures in Windows CI. mockReaddir.mockRejectedValueOnce( new Error('EACCES: permission denied, scandir'), ); const requestConsent = vi.fn().mockResolvedValue(true); await maybeRequestConsentOrFail( baseConfig, requestConsent, false, undefined, false, [skill], ); expect(requestConsent).toHaveBeenCalledTimes(1); let consentString = requestConsent.mock.calls[0][0] as string; consentString = normalizePathsForSnapshot(consentString, tempDir); await expectConsentSnapshot(consentString); }); }); }); describe('skillsConsentString', () => { it('should generate a consent string for skills', async () => { const skill1Dir = path.join(tempDir, 'skill1'); await fs.mkdir(skill1Dir, { recursive: true }); await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); const skill1: SkillDefinition = { name: 'skill1', description: 'desc1', location: path.join(skill1Dir, 'SKILL.md'), body: 'body1', }; const { skillsConsentString } = await import('./consent.js'); let consentString = await skillsConsentString( [skill1], 'https://example.com/repo.git', '/mock/target/dir', ); consentString = normalizePathsForSnapshot(consentString, tempDir); await expectConsentSnapshot(consentString); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/consent.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; import chalk from 'chalk'; import type { ConfirmationRequest } from '../../ui/types.js'; import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js'; import type { ExtensionConfig } from '../extension.js'; export const INSTALL_WARNING_MESSAGE = chalk.yellow( 'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.', ); export const SKILLS_WARNING_MESSAGE = chalk.yellow( "Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.", ); /** * Builds a consent string for installing agent skills. */ export async function skillsConsentString( skills: SkillDefinition[], source: string, targetDir?: string, isLink = false, ): Promise { const action = isLink ? 'Linking' : 'Installing'; const output: string[] = []; output.push(`${action} agent skill(s) from "${source}".`); output.push( `\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`, ); output.push(...(await renderSkillsList(skills))); if (targetDir) { const destLabel = isLink ? 'Link' : 'Install'; output.push(`${destLabel} Destination: ${targetDir}`); } output.push('\n' + SKILLS_WARNING_MESSAGE); return output.join('\n'); } /** * Requests consent from the user to perform an action, by reading a Y/n * character from stdin. * * This should not be called from interactive mode as it will break the CLI. * * @param consentDescription The description of the thing they will be consenting to. * @returns boolean, whether they consented or not. */ export async function requestConsentNonInteractive( consentDescription: string, ): Promise { debugLogger.log(consentDescription); const result = await promptForConsentNonInteractive( 'Do you want to continue? [Y/n]: ', ); return result; } /** * Requests consent from the user to perform an action, in interactive mode. * * This should not be called from non-interactive mode as it will not work. * * @param consentDescription The description of the thing they will be consenting to. * @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. * @returns boolean, whether they consented or not. */ export async function requestConsentInteractive( consentDescription: string, addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, ): Promise { return promptForConsentInteractive( consentDescription + '\n\nDo you want to continue?', addExtensionUpdateConfirmationRequest, ); } /** * Asks users a prompt and awaits for a y/n response on stdin. * * This should not be called from interactive mode as it will break the CLI. * * @param prompt A yes/no prompt to ask the user * @param defaultValue Whether to resolve as true or false on enter. * @returns Whether or not the user answers 'y' (yes). */ export async function promptForConsentNonInteractive( prompt: string, defaultValue = true, ): Promise { const readline = await import('node:readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); const trimmedAnswer = answer.trim().toLowerCase(); if (trimmedAnswer === '') { resolve(defaultValue); } else { resolve(['y', 'yes'].includes(trimmedAnswer)); } }); }); } /** * Asks users an interactive yes/no prompt. * * This should not be called from non-interactive mode as it will break the CLI. * * @param prompt A markdown prompt to ask the user * @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. * @returns Whether or not the user answers yes. */ async function promptForConsentInteractive( prompt: string, addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, ): Promise { return new Promise((resolve) => { addExtensionUpdateConfirmationRequest({ prompt, onConfirm: (resolvedConfirmed) => { resolve(resolvedConfirmed); }, }); }); } /** * Builds a consent string for installing an extension based on it's * extensionConfig. */ async function extensionConsentString( extensionConfig: ExtensionConfig, hasHooks: boolean, skills: SkillDefinition[] = [], previousName?: string, wasMigrated?: boolean, ): Promise { const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig); const output: string[] = []; const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); if (wasMigrated) { if (previousName && previousName !== sanitizedConfig.name) { output.push( `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`, ); } else { output.push( `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`, ); } } else if (previousName && previousName !== sanitizedConfig.name) { output.push( `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`, ); } else { output.push(`Installing extension "${sanitizedConfig.name}".`); } if (mcpServerEntries.length) { output.push('This extension will run the following MCP servers:'); for (const [key, mcpServer] of mcpServerEntries) { const isLocal = !!mcpServer.command; const source = mcpServer.httpUrl ?? `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`; output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`); } } if (sanitizedConfig.contextFileName) { output.push( `This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`, ); } if (sanitizedConfig.excludeTools) { output.push( `This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`, ); } if (hasHooks) { output.push( '⚠️ This extension contains Hooks which can automatically execute commands.', ); } if (skills.length > 0) { output.push(`\n${chalk.bold('Agent Skills:')}`); output.push('\nThis extension will install the following agent skills:\n'); output.push(...(await renderSkillsList(skills))); } output.push('\n' + INSTALL_WARNING_MESSAGE); if (skills.length > 0) { output.push('\n' + SKILLS_WARNING_MESSAGE); } return output.join('\n'); } /** * Shared logic for formatting a list of agent skills for a consent prompt. */ async function renderSkillsList(skills: SkillDefinition[]): Promise { const output: string[] = []; for (const skill of skills) { output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); const skillDir = path.dirname(skill.location); let fileCountStr = ''; try { const skillDirItems = await fs.readdir(skillDir); fileCountStr = ` (${skillDirItems.length} items in directory)`; } catch { fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`; } output.push(chalk.dim(` (Source: ${skill.location})${fileCountStr}`)); output.push(''); } return output; } /** * Requests consent from the user to install an extension (extensionConfig), if * there is any difference between the consent string for `extensionConfig` and * `previousExtensionConfig`. * * Always requests consent if previousExtensionConfig is null. * * Throws if the user does not consent. */ export async function maybeRequestConsentOrFail( extensionConfig: ExtensionConfig, requestConsent: (consent: string) => Promise, hasHooks: boolean, previousExtensionConfig?: ExtensionConfig, previousHasHooks?: boolean, skills: SkillDefinition[] = [], previousSkills: SkillDefinition[] = [], isMigrating: boolean = false, ) { const extensionConsent = await extensionConsentString( extensionConfig, hasHooks, skills, previousExtensionConfig?.name, isMigrating, ); if (previousExtensionConfig) { const previousExtensionConsent = await extensionConsentString( previousExtensionConfig, previousHasHooks ?? false, previousSkills, ); if (previousExtensionConsent === extensionConsent) { return; } } if (!(await requestConsent(extensionConsent))) { throw new Error(`Installation cancelled for "${extensionConfig.name}".`); } } ================================================ FILE: packages/cli/src/config/extensions/extensionEnablement.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; import { ExtensionStorage } from './storage.js'; vi.mock('./storage.js'); import { coreEvents, GEMINI_DIR, type GeminiCLIExtension, } from '@google/gemini-cli-core'; vi.mock('node:os', () => ({ homedir: vi.fn().mockReturnValue('/virtual-home'), tmpdir: vi.fn().mockReturnValue('/virtual-tmp'), })); const inMemoryFs: { [key: string]: string } = {}; // Helper to create a temporary directory for testing function createTestDir() { const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`; inMemoryFs[dirPath] = ''; // Simulate directory existence return { path: dirPath, cleanup: () => { for (const key in inMemoryFs) { if (key.startsWith(dirPath)) { delete inMemoryFs[key]; } } }, }; } let testDir: { path: string; cleanup: () => void }; let manager: ExtensionEnablementManager; describe('ExtensionEnablementManager', () => { beforeEach(() => { // Clear the in-memory file system before each test for (const key in inMemoryFs) { delete inMemoryFs[key]; } expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion // Mock fs functions vi.spyOn(fs, 'readFileSync').mockImplementation( (path: fs.PathOrFileDescriptor) => { const content = inMemoryFs[path.toString()]; if (content === undefined) { const error = new Error( `ENOENT: no such file or directory, open '${path}'`, ); (error as NodeJS.ErrnoException).code = 'ENOENT'; throw error; } return content; }, ); vi.spyOn(fs, 'writeFileSync').mockImplementation( ( path: fs.PathOrFileDescriptor, data: string | ArrayBufferView, ) => { inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs }, ); vi.spyOn(fs, 'mkdirSync').mockImplementation( ( _path: fs.PathLike, _options?: fs.MakeDirectoryOptions | fs.Mode | null, ) => undefined, ); vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => { const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`; return virtualPath; }); vi.spyOn(fs, 'rmSync').mockImplementation(() => {}); testDir = createTestDir(); vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue( path.join(testDir.path, GEMINI_DIR), ); manager = new ExtensionEnablementManager(); }); afterEach(() => { vi.restoreAllMocks(); // Reset the singleton instance for test isolation // eslint-disable-next-line @typescript-eslint/no-explicit-any (ExtensionEnablementManager as any).instance = undefined; }); describe('isEnabled', () => { it('should return true if extension is not configured', () => { expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); }); it('should return true if no overrides match', () => { manager.disable('ext-test', false, '/another/path'); expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); }); it('should enable a path based on an override rule', () => { manager.disable('ext-test', true, '/'); manager.enable('ext-test', true, '/home/user/projects/'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( true, ); }); it('should disable a path based on a disable override rule', () => { manager.enable('ext-test', true, '/'); manager.disable('ext-test', true, '/home/user/projects/'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( false, ); }); it('should respect the last matching rule (enable wins)', () => { manager.disable('ext-test', true, '/home/user/projects/'); manager.enable('ext-test', false, '/home/user/projects/my-app'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( true, ); }); it('should respect the last matching rule (disable wins)', () => { manager.enable('ext-test', true, '/home/user/projects/'); manager.disable('ext-test', false, '/home/user/projects/my-app'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( false, ); }); it('should handle overlapping rules correctly', () => { manager.enable('ext-test', true, '/home/user/projects'); manager.disable('ext-test', false, '/home/user/projects/my-app'); expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( false, ); expect( manager.isEnabled('ext-test', '/home/user/projects/something-else'), ).toBe(true); }); }); describe('remove', () => { it('should remove an extension from the config', () => { manager.enable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); expect(config['ext-test']).toBeDefined(); manager.remove('ext-test'); const newConfig = manager.readConfig(); expect(newConfig['ext-test']).toBeUndefined(); }); it('should not throw when removing a non-existent extension', () => { const config = manager.readConfig(); expect(config['ext-test']).toBeUndefined(); expect(() => manager.remove('ext-test')).not.toThrow(); }); }); describe('readConfig', () => { it('should return an empty object if the config file is corrupted', () => { const configPath = path.join( testDir.path, GEMINI_DIR, 'extension-enablement.json', ); fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, 'not a json'); const config = manager.readConfig(); expect(config).toEqual({}); }); it('should return an empty object on generic read error', () => { vi.spyOn(fs, 'readFileSync').mockImplementation(() => { throw new Error('Read error'); }); const config = manager.readConfig(); expect(config).toEqual({}); }); }); describe('includeSubdirs', () => { it('should add a glob when enabling with includeSubdirs', () => { manager.enable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); }); it('should not add a glob when enabling without includeSubdirs', () => { manager.enable('ext-test', false, '/path/to/dir'); const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('/path/to/dir/'); expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should add a glob when disabling with includeSubdirs', () => { manager.disable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('!/path/to/dir/*'); }); it('should remove conflicting glob rule when enabling without subdirs', () => { manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir* manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('/path/to/dir/'); expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should remove conflicting non-glob rule when enabling with subdirs', () => { manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); expect(config['ext-test'].overrides).not.toContain('/path/to/dir/'); }); it('should remove conflicting rules when disabling', () => { manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob manager.disable('ext-test', false, '/path/to/dir'); // disabled without const config = manager.readConfig(); expect(config['ext-test'].overrides).toContain('!/path/to/dir/'); expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); }); it('should correctly evaluate isEnabled with subdirs', () => { manager.disable('ext-test', true, '/'); manager.enable('ext-test', true, '/path/to/dir'); expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true); expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true); expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false); }); it('should correctly evaluate isEnabled without subdirs', () => { manager.disable('ext-test', true, '/*'); manager.enable('ext-test', false, '/path/to/dir'); expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false); }); }); describe('pruning child rules', () => { it('should remove child rules when enabling a parent with subdirs', () => { // Pre-existing rules for children manager.enable('ext-test', false, '/path/to/dir/subdir1'); manager.disable('ext-test', true, '/path/to/dir/subdir2'); manager.enable('ext-test', false, '/path/to/another/dir'); // Enable the parent directory manager.enable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); const overrides = config['ext-test'].overrides; // The new parent rule should be present expect(overrides).toContain(`/path/to/dir/*`); // Child rules should be removed expect(overrides).not.toContain('/path/to/dir/subdir1/'); expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); // Unrelated rules should remain expect(overrides).toContain('/path/to/another/dir/'); }); it('should remove child rules when disabling a parent with subdirs', () => { // Pre-existing rules for children manager.enable('ext-test', false, '/path/to/dir/subdir1'); manager.disable('ext-test', true, '/path/to/dir/subdir2'); manager.enable('ext-test', false, '/path/to/another/dir'); // Disable the parent directory manager.disable('ext-test', true, '/path/to/dir'); const config = manager.readConfig(); const overrides = config['ext-test'].overrides; // The new parent rule should be present expect(overrides).toContain(`!/path/to/dir/*`); // Child rules should be removed expect(overrides).not.toContain('/path/to/dir/subdir1/'); expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); // Unrelated rules should remain expect(overrides).toContain('/path/to/another/dir/'); }); it('should not remove child rules if includeSubdirs is false', () => { manager.enable('ext-test', false, '/path/to/dir/subdir1'); manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs const config = manager.readConfig(); const overrides = config['ext-test'].overrides; expect(overrides).toContain('/path/to/dir/subdir1/'); expect(overrides).toContain('/path/to/dir/'); }); }); it('should correctly prioritize more specific enable rules', () => { manager.disable('ext-test', true, '/Users/chrstn'); manager.enable('ext-test', true, '/Users/chrstn/gemini-cli'); expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( true, ); }); it('should not disable subdirectories if includeSubdirs is false', () => { manager.disable('ext-test', false, '/Users/chrstn'); expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( true, ); }); describe('extension overrides (-e )', () => { beforeEach(() => { manager = new ExtensionEnablementManager(['ext-test']); }); it('can enable extensions, case-insensitive', () => { manager.disable('ext-test', true, '/'); expect(manager.isEnabled('ext-test', '/')).toBe(true); expect(manager.isEnabled('Ext-Test', '/')).toBe(true); // Double check that it would have been disabled otherwise expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe( false, ); }); it('disable all other extensions', () => { manager = new ExtensionEnablementManager(['ext-test']); manager.enable('ext-test-2', true, '/'); expect(manager.isEnabled('ext-test-2', '/')).toBe(false); // Double check that it would have been enabled otherwise expect( new ExtensionEnablementManager().isEnabled('ext-test-2', '/'), ).toBe(true); }); it('none disables all extensions', () => { manager = new ExtensionEnablementManager(['none']); manager.enable('ext-test', true, '/'); expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false); // Double check that it would have been enabled otherwise expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe( true, ); }); }); describe('validateExtensionOverrides', () => { let coreEventsEmitSpy: ReturnType; beforeEach(() => { coreEventsEmitSpy = vi.spyOn(coreEvents, 'emitFeedback'); }); afterEach(() => { coreEventsEmitSpy.mockRestore(); }); it('should not log an error if enabledExtensionNamesOverride is empty', () => { const manager = new ExtensionEnablementManager([]); manager.validateExtensionOverrides([]); expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); it('should not log an error if all enabledExtensionNamesOverride are valid', () => { const manager = new ExtensionEnablementManager(['ext-one', 'ext-two']); const extensions = [ { name: 'ext-one' }, { name: 'ext-two' }, ] as GeminiCLIExtension[]; manager.validateExtensionOverrides(extensions); expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => { const manager = new ExtensionEnablementManager([ 'ext-one', 'ext-invalid', 'ext-another-invalid', ]); const extensions = [ { name: 'ext-one' }, { name: 'ext-two' }, ] as GeminiCLIExtension[]; manager.validateExtensionOverrides(extensions); expect(coreEventsEmitSpy).toHaveBeenCalledTimes(2); expect(coreEventsEmitSpy).toHaveBeenCalledWith( 'error', 'Extension not found: ext-invalid', ); expect(coreEventsEmitSpy).toHaveBeenCalledWith( 'error', 'Extension not found: ext-another-invalid', ); }); it('should not log an error if "none" is in enabledExtensionNamesOverride', () => { const manager = new ExtensionEnablementManager(['none']); manager.validateExtensionOverrides([]); expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); }); }); describe('Override', () => { it('should create an override from input', () => { const override = Override.fromInput('/path/to/dir', true); expect(override.baseRule).toBe(`/path/to/dir/`); expect(override.isDisable).toBe(false); expect(override.includeSubdirs).toBe(true); }); it('should create a disable override from input', () => { const override = Override.fromInput('!/path/to/dir', false); expect(override.baseRule).toBe(`/path/to/dir/`); expect(override.isDisable).toBe(true); expect(override.includeSubdirs).toBe(false); }); it('should create an override from a file rule', () => { const override = Override.fromFileRule('/path/to/dir/'); expect(override.baseRule).toBe('/path/to/dir/'); expect(override.isDisable).toBe(false); expect(override.includeSubdirs).toBe(false); }); it('should create an override from a file rule without a trailing slash', () => { const override = Override.fromFileRule('/path/to/dir'); expect(override.baseRule).toBe('/path/to/dir'); expect(override.isDisable).toBe(false); expect(override.includeSubdirs).toBe(false); }); it('should create a disable override from a file rule', () => { const override = Override.fromFileRule('!/path/to/dir/'); expect(override.isDisable).toBe(true); expect(override.baseRule).toBe('/path/to/dir/'); expect(override.includeSubdirs).toBe(false); }); it('should create an override with subdirs from a file rule', () => { const override = Override.fromFileRule('/path/to/dir/*'); expect(override.baseRule).toBe('/path/to/dir/'); expect(override.isDisable).toBe(false); expect(override.includeSubdirs).toBe(true); }); it('should correctly identify conflicting overrides', () => { const override1 = Override.fromInput('/path/to/dir', true); const override2 = Override.fromInput('/path/to/dir', false); expect(override1.conflictsWith(override2)).toBe(true); }); it('should correctly identify non-conflicting overrides', () => { const override1 = Override.fromInput('/path/to/dir', true); const override2 = Override.fromInput('/path/to/another/dir', true); expect(override1.conflictsWith(override2)).toBe(false); }); it('should correctly identify equal overrides', () => { const override1 = Override.fromInput('/path/to/dir', true); const override2 = Override.fromInput('/path/to/dir', true); expect(override1.isEqualTo(override2)).toBe(true); }); it('should correctly identify unequal overrides', () => { const override1 = Override.fromInput('/path/to/dir', true); const override2 = Override.fromInput('!/path/to/dir', true); expect(override1.isEqualTo(override2)).toBe(false); }); it('should generate the correct regex', () => { const override = Override.fromInput('/path/to/dir', true); const regex = override.asRegex(); expect(regex.test('/path/to/dir/')).toBe(true); expect(regex.test('/path/to/dir/subdir')).toBe(true); expect(regex.test('/path/to/another/dir')).toBe(false); }); it('should correctly identify child overrides', () => { const parent = Override.fromInput('/path/to/dir', true); const child = Override.fromInput('/path/to/dir/subdir', false); expect(child.isChildOf(parent)).toBe(true); }); it('should correctly identify child overrides with glob', () => { const parent = Override.fromInput('/path/to/dir/*', true); const child = Override.fromInput('/path/to/dir/subdir', false); expect(child.isChildOf(parent)).toBe(true); }); it('should correctly identify non-child overrides', () => { const parent = Override.fromInput('/path/to/dir', true); const other = Override.fromInput('/path/to/another/dir', false); expect(other.isChildOf(parent)).toBe(false); }); it('should generate the correct output string', () => { const override = Override.fromInput('/path/to/dir', true); expect(override.output()).toBe(`/path/to/dir/*`); }); it('should generate the correct output string for a disable override', () => { const override = Override.fromInput('!/path/to/dir', false); expect(override.output()).toBe(`!/path/to/dir/`); }); it('should disable a path based on a disable override rule', () => { const override = Override.fromInput('!/path/to/dir', false); expect(override.output()).toBe(`!/path/to/dir/`); }); }); ================================================ FILE: packages/cli/src/config/extensions/extensionEnablement.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs'; import path from 'node:path'; import { coreEvents, type GeminiCLIExtension } from '@google/gemini-cli-core'; import { ExtensionStorage } from './storage.js'; export interface ExtensionEnablementConfig { overrides: string[]; } export interface AllExtensionsEnablementConfig { [extensionName: string]: ExtensionEnablementConfig; } export class Override { constructor( public baseRule: string, public isDisable: boolean, public includeSubdirs: boolean, ) {} static fromInput(inputRule: string, includeSubdirs: boolean): Override { const isDisable = inputRule.startsWith('!'); let baseRule = isDisable ? inputRule.substring(1) : inputRule; baseRule = ensureLeadingAndTrailingSlash(baseRule); return new Override(baseRule, isDisable, includeSubdirs); } static fromFileRule(fileRule: string): Override { const isDisable = fileRule.startsWith('!'); let baseRule = isDisable ? fileRule.substring(1) : fileRule; const includeSubdirs = baseRule.endsWith('*'); baseRule = includeSubdirs ? baseRule.substring(0, baseRule.length - 1) : baseRule; return new Override(baseRule, isDisable, includeSubdirs); } conflictsWith(other: Override): boolean { if (this.baseRule === other.baseRule) { return ( this.includeSubdirs !== other.includeSubdirs || this.isDisable !== other.isDisable ); } return false; } isEqualTo(other: Override): boolean { return ( this.baseRule === other.baseRule && this.includeSubdirs === other.includeSubdirs && this.isDisable === other.isDisable ); } asRegex(): RegExp { return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`); } isChildOf(parent: Override) { if (!parent.includeSubdirs) { return false; } return parent.asRegex().test(this.baseRule); } output(): string { return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`; } matchesPath(path: string) { return this.asRegex().test(path); } } const ensureLeadingAndTrailingSlash = function (dirPath: string): string { // Normalize separators to forward slashes for consistent matching across platforms. let result = dirPath.replace(/\\/g, '/'); if (result.charAt(0) !== '/') { result = '/' + result; } if (result.charAt(result.length - 1) !== '/') { result = result + '/'; } return result; }; /** * Converts a glob pattern to a RegExp object. * This is a simplified implementation that supports `*`. * * @param glob The glob pattern to convert. * @returns A RegExp object. */ function globToRegex(glob: string): RegExp { const regexString = glob .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters .replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group return new RegExp(`^${regexString}$`); } export class ExtensionEnablementManager { private configFilePath: string; private configDir: string; // If non-empty, this overrides all other extension configuration and enables // only the ones in this list. private enabledExtensionNamesOverride: string[]; constructor(enabledExtensionNames?: string[]) { this.configDir = ExtensionStorage.getUserExtensionsDir(); this.configFilePath = path.join( this.configDir, 'extension-enablement.json', ); this.enabledExtensionNamesOverride = enabledExtensionNames?.map((name) => name.toLowerCase()) ?? []; } validateExtensionOverrides(extensions: GeminiCLIExtension[]) { for (const name of this.enabledExtensionNamesOverride) { if (name === 'none') continue; if ( !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase()) ) { coreEvents.emitFeedback('error', `Extension not found: ${name}`); } } } /** * Determines if an extension is enabled based on its name and the current * path. The last matching rule in the overrides list wins. * * @param extensionName The name of the extension. * @param currentPath The absolute path of the current working directory. * @returns True if the extension is enabled, false otherwise. */ isEnabled(extensionName: string, currentPath: string): boolean { // If we have a single override called 'none', this disables all extensions. // Typically, this comes from the user passing `-e none`. if ( this.enabledExtensionNamesOverride.length === 1 && this.enabledExtensionNamesOverride[0] === 'none' ) { return false; } // If we have explicit overrides, only enable those extensions. if (this.enabledExtensionNamesOverride.length > 0) { // When checking against overrides ONLY, we use a case insensitive match. // The override names are already lowercased in the constructor. return this.enabledExtensionNamesOverride.includes( extensionName.toLocaleLowerCase(), ); } // Otherwise, we use the configuration settings const config = this.readConfig(); const extensionConfig = config[extensionName]; // Extensions are enabled by default. let enabled = true; const allOverrides = extensionConfig?.overrides ?? []; for (const rule of allOverrides) { const override = Override.fromFileRule(rule); if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) { enabled = !override.isDisable; } } return enabled; } readConfig(): AllExtensionsEnablementConfig { try { const content = fs.readFileSync(this.configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (error) { if ( error instanceof Error && 'code' in error && error.code === 'ENOENT' ) { return {}; } coreEvents.emitFeedback( 'error', 'Failed to read extension enablement config.', error, ); return {}; } } writeConfig(config: AllExtensionsEnablementConfig): void { fs.mkdirSync(this.configDir, { recursive: true }); fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2)); } enable( extensionName: string, includeSubdirs: boolean, scopePath: string, ): void { const config = this.readConfig(); if (!config[extensionName]) { config[extensionName] = { overrides: [] }; } const override = Override.fromInput(scopePath, includeSubdirs); const overrides = config[extensionName].overrides.filter((rule) => { const fileOverride = Override.fromFileRule(rule); if ( fileOverride.conflictsWith(override) || fileOverride.isEqualTo(override) ) { return false; // Remove conflicts and equivalent values. } return !fileOverride.isChildOf(override); }); overrides.push(override.output()); config[extensionName].overrides = overrides; this.writeConfig(config); } disable( extensionName: string, includeSubdirs: boolean, scopePath: string, ): void { this.enable(extensionName, includeSubdirs, `!${scopePath}`); } remove(extensionName: string): void { const config = this.readConfig(); if (config[extensionName]) { delete config[extensionName]; this.writeConfig(config); } } } ================================================ FILE: packages/cli/src/config/extensions/extensionSettings.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'node:path'; import * as os from 'node:os'; import { getEnvContents, maybePromptForSettings, promptForSetting, type ExtensionSetting, updateSetting, ExtensionSettingScope, getScopedEnvContents, } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; import { KeychainTokenStorage } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; vi.mock('prompts'); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, homedir: vi.fn(), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, KeychainTokenStorage: vi.fn(), }; }); describe('extensionSettings', () => { let tempHomeDir: string; let tempWorkspaceDir: string; let extensionDir: string; let mockKeychainData: Record>; beforeEach(() => { vi.clearAllMocks(); mockKeychainData = {}; vi.mocked(KeychainTokenStorage).mockImplementation( (serviceName: string) => { if (!mockKeychainData[serviceName]) { mockKeychainData[serviceName] = {}; } const keychainData = mockKeychainData[serviceName]; return { getSecret: vi .fn() .mockImplementation( async (key: string) => keychainData[key] || null, ), setSecret: vi .fn() .mockImplementation(async (key: string, value: string) => { keychainData[key] = value; }), deleteSecret: vi.fn().mockImplementation(async (key: string) => { delete keychainData[key]; }), listSecrets: vi .fn() .mockImplementation(async () => Object.keys(keychainData)), isAvailable: vi.fn().mockResolvedValue(true), } as unknown as KeychainTokenStorage; }, ); tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; tempWorkspaceDir = path.join( os.tmpdir(), `gemini-cli-test-workspace-${Date.now()}`, ); extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); // Spy and mock the method, but also create the directory so we can write to it. vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( extensionDir, ); fs.mkdirSync(extensionDir, { recursive: true }); fs.mkdirSync(tempWorkspaceDir, { recursive: true }); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); vi.mocked(prompts).mockClear(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); describe('maybePromptForSettings', () => { const mockRequestSetting = vi.fn( async (setting: ExtensionSetting) => `mock-${setting.envVar}`, ); beforeEach(() => { mockRequestSetting.mockClear(); }); it('should do nothing if settings are undefined', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); it('should do nothing if settings are empty', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); it('should prompt for all settings if there is no previous config', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).toHaveBeenCalledTimes(2); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); }); it('should only prompt for new settings', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const previousSettings = { VAR1: 'previous-VAR1' }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).toHaveBeenCalledTimes(1); expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n'; expect(actualContent).toBe(expectedContent); }); it('should clear settings if new config has no settings', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; const previousSettings = { VAR1: 'previous-VAR1', SENSITIVE_VAR: 'secret', }; const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); const envPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1'); await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const actualContent = await fsPromises.readFile(envPath, 'utf-8'); expect(actualContent).toBe(''); expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); }); it('should remove sensitive settings from keychain', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; const previousSettings = { SENSITIVE_VAR: 'secret' }; const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); }); it('should remove settings that are no longer in the config', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; const previousSettings = { VAR1: 'previous-VAR1', VAR2: 'previous-VAR2', }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\n'; expect(actualContent).toBe(expectedContent); }); it('should reprompt if a setting changes sensitivity', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true }, ], }; const previousSettings = { VAR1: 'previous-VAR1' }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).toHaveBeenCalledTimes(1); expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]); // The value should now be in keychain, not the .env file. const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toBe(''); }); it('should not prompt if settings are identical', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const previousSettings = { VAR1: 'previous-VAR1', VAR2: 'previous-VAR2', }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n'; expect(actualContent).toBe(expectedContent); }); it('should wrap values with spaces in quotes', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; mockRequestSetting.mockResolvedValue('a value with spaces'); await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toBe('VAR1="a value with spaces"\n'); }); it('should not set sensitive settings if the value is empty during initial setup', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; mockRequestSetting.mockResolvedValue(''); await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); }); it('should not attempt to clear secrets if keychain is unavailable', async () => { // Arrange const mockIsAvailable = vi.fn().mockResolvedValue(false); const mockListSecrets = vi.fn(); vi.mocked(KeychainTokenStorage).mockImplementation( () => ({ isAvailable: mockIsAvailable, listSecrets: mockListSecrets, deleteSecret: vi.fn(), getSecret: vi.fn(), setSecret: vi.fn(), }) as unknown as KeychainTokenStorage, ); const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], // Empty settings triggers clearSettings }; const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; // Act await maybePromptForSettings( config, '12345', mockRequestSetting, previousConfig, undefined, ); // Assert expect(mockIsAvailable).toHaveBeenCalled(); expect(mockListSecrets).not.toHaveBeenCalled(); }); }); describe('promptForSetting', () => { it.each([ { description: 'should use prompts with type "password" for sensitive settings', setting: { name: 'API Key', description: 'Your secret key', envVar: 'API_KEY', sensitive: true, }, expectedType: 'password', promptValue: 'secret-key', }, { description: 'should use prompts with type "text" for non-sensitive settings', setting: { name: 'Username', description: 'Your public username', envVar: 'USERNAME', sensitive: false, }, expectedType: 'text', promptValue: 'test-user', }, { description: 'should default to "text" if sensitive is undefined', setting: { name: 'Username', description: 'Your public username', envVar: 'USERNAME', }, expectedType: 'text', promptValue: 'test-user', }, ])('$description', async ({ setting, expectedType, promptValue }) => { vi.mocked(prompts).mockResolvedValue({ value: promptValue }); const result = await promptForSetting(setting as ExtensionSetting); expect(prompts).toHaveBeenCalledWith({ type: expectedType, name: 'value', message: `${setting.name}\n${setting.description}`, }); expect(result).toBe(promptValue); }); it('should return undefined if the user cancels the prompt', async () => { vi.mocked(prompts).mockResolvedValue({ value: undefined }); const result = await promptForSetting({ name: 'Test', description: 'Test desc', envVar: 'TEST_VAR', }); expect(result).toBeUndefined(); }); }); describe('getScopedEnvContents', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; const extensionId = '12345'; it('should return combined contents from user .env and keychain for USER scope', async () => { const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret'); const contents = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.USER, tempWorkspaceDir, ); expect(contents).toEqual({ VAR1: 'user-value1', SENSITIVE_VAR: 'user-secret', }); }); it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => { const workspaceEnvPath = path.join( tempWorkspaceDir, EXTENSION_SETTINGS_FILENAME, ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); const workspaceKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); const contents = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ); expect(contents).toEqual({ VAR1: 'workspace-value1', SENSITIVE_VAR: 'workspace-secret', }); }); it('should ignore .env if it is a directory', async () => { const workspaceEnvPath = path.join( tempWorkspaceDir, EXTENSION_SETTINGS_FILENAME, ); fs.mkdirSync(workspaceEnvPath); const workspaceKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); const contents = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ); expect(contents).toEqual({ SENSITIVE_VAR: 'workspace-secret', }); }); }); describe('getEnvContents (merged)', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, { name: 's3', description: 'd3', envVar: 'VAR3' }, ], }; const extensionId = '12345'; it('should merge user and workspace settings, with workspace taking precedence', async () => { // User settings const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); await fsPromises.writeFile( userEnvPath, 'VAR1=user-value1\nVAR3=user-value3', ); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext ${extensionId}`, ); await userKeychain.setSecret('VAR2', 'user-secret2'); // Workspace settings const workspaceEnvPath = path.join( tempWorkspaceDir, EXTENSION_SETTINGS_FILENAME, ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); const workspaceKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); const contents = await getEnvContents( config, extensionId, tempWorkspaceDir, ); expect(contents).toEqual({ VAR1: 'workspace-value1', VAR2: 'workspace-secret2', VAR3: 'user-value3', }); }); }); describe('updateSetting', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, ], }; const mockRequestSetting = vi.fn(); beforeEach(async () => { const userEnvPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('VAR2', 'value2'); mockRequestSetting.mockClear(); }); it('should update a non-sensitive setting in USER scope', async () => { mockRequestSetting.mockResolvedValue('new-value1'); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1=new-value1'); }); it('should update a non-sensitive setting in WORKSPACE scope', async () => { mockRequestSetting.mockResolvedValue('new-workspace-value'); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ); const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1=new-workspace-value'); }); it('should throw an error when trying to write to a workspace with a .env directory', async () => { const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); fs.mkdirSync(workspaceEnvPath); mockRequestSetting.mockResolvedValue('new-workspace-value'); await expect( updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ), ).rejects.toThrow( /Cannot write extension settings to .* because it is a directory./, ); }); it('should update a sensitive setting in USER scope', async () => { mockRequestSetting.mockResolvedValue('new-value2'); await updateSetting( config, '12345', 'VAR2', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); }); it('should update a sensitive setting in WORKSPACE scope', async () => { mockRequestSetting.mockResolvedValue('new-workspace-secret'); await updateSetting( config, '12345', 'VAR2', mockRequestSetting, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ); const workspaceKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); expect(await workspaceKeychain.getSecret('VAR2')).toBe( 'new-workspace-secret', ); }); it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => { // Setup a pre-existing .env file in the workspace with unmanaged variables const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); const originalEnvContent = 'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension await fsPromises.writeFile(workspaceEnvPath, originalEnvContent); // Simulate updating an extension-managed non-sensitive setting mockRequestSetting.mockResolvedValue('updated-value'); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.WORKSPACE, tempWorkspaceDir, ); // Read the .env file after update const actualContent = await fsPromises.readFile( workspaceEnvPath, 'utf-8', ); // Assert that original variables are intact and extension variable is updated expect(actualContent).toContain('PROJECT_VAR_1=value_1'); expect(actualContent).toContain('PROJECT_VAR_2=value_2'); expect(actualContent).toContain('VAR1=updated-value'); // Ensure no other unexpected changes or deletions const lines = actualContent.split('\n').filter((line) => line.length > 0); expect(lines).toHaveLength(3); // Should only have the three variables }); it('should delete a sensitive setting if the new value is empty', async () => { mockRequestSetting.mockResolvedValue(''); await updateSetting( config, '12345', 'VAR2', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('VAR2')).toBeNull(); }); it('should delete a non-sensitive setting if the new value is empty', async () => { mockRequestSetting.mockResolvedValue(''); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).not.toContain('VAR1='); }); it('should not throw if deleting a non-existent sensitive setting with empty value', async () => { mockRequestSetting.mockResolvedValue(''); // Ensure it doesn't exist first const userKeychain = new KeychainTokenStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.deleteSecret('VAR2'); await updateSetting( config, '12345', 'VAR2', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); // Should complete without error }); it('should throw error if env var name contains invalid characters', async () => { const securityConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's2', description: 'd2', envVar: 'VAR-BAD' }], }; mockRequestSetting.mockResolvedValue('value'); await expect( updateSetting( securityConfig, '12345', 'VAR-BAD', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ), ).rejects.toThrow(/Invalid environment variable name/); }); it('should throw error if env var value contains newlines', async () => { mockRequestSetting.mockResolvedValue('value\nwith\nnewlines'); await expect( updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ), ).rejects.toThrow(/Invalid environment variable value/); }); it('should quote values with spaces', async () => { mockRequestSetting.mockResolvedValue('value with spaces'); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1="value with spaces"'); }); it('should escape quotes in values', async () => { mockRequestSetting.mockResolvedValue('value with "quotes"'); await updateSetting( config, '12345', 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, tempWorkspaceDir, ); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1="value with \\"quotes\\""'); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/extensionSettings.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as dotenv from 'dotenv'; import * as path from 'node:path'; import { ExtensionStorage } from './storage.js'; import type { ExtensionConfig } from '../extension.js'; import prompts from 'prompts'; import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; export enum ExtensionSettingScope { USER = 'user', WORKSPACE = 'workspace', } export interface ExtensionSetting { name: string; description: string; envVar: string; // NOTE: If no value is set, this setting will be considered NOT sensitive. sensitive?: boolean; } const getKeychainStorageName = ( extensionName: string, extensionId: string, scope: ExtensionSettingScope, workspaceDir?: string, ): string => { const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`; if (scope === ExtensionSettingScope.WORKSPACE) { if (!workspaceDir) { throw new Error('Workspace directory is required for workspace scope'); } return `${base} ${workspaceDir}`; } return base; }; export const getEnvFilePath = ( extensionName: string, scope: ExtensionSettingScope, workspaceDir?: string, ): string => { if (scope === ExtensionSettingScope.WORKSPACE) { if (!workspaceDir) { throw new Error('Workspace directory is required for workspace scope'); } return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME); } return new ExtensionStorage(extensionName).getEnvFilePath(); }; export async function maybePromptForSettings( extensionConfig: ExtensionConfig, extensionId: string, requestSetting: (setting: ExtensionSetting) => Promise, previousExtensionConfig?: ExtensionConfig, previousSettings?: Record, ): Promise { const { name: extensionName, settings } = extensionConfig; if ( (!settings || settings.length === 0) && (!previousExtensionConfig?.settings || previousExtensionConfig.settings.length === 0) ) { return; } // We assume user scope here because we don't have a way to ask the user for scope during the initial setup. // The user can change the scope later using the `settings set` command. const scope = ExtensionSettingScope.USER; const envFilePath = getEnvFilePath(extensionName, scope); const keychain = new KeychainTokenStorage( getKeychainStorageName(extensionName, extensionId, scope), ); if (!settings || settings.length === 0) { await clearSettings(envFilePath, keychain); return; } const settingsChanges = getSettingsChanges( settings, previousExtensionConfig?.settings ?? [], ); const allSettings: Record = { ...previousSettings }; for (const removedEnvSetting of settingsChanges.removeEnv) { delete allSettings[removedEnvSetting.envVar]; } for (const removedSensitiveSetting of settingsChanges.removeSensitive) { await keychain.deleteSecret(removedSensitiveSetting.envVar); } for (const setting of settingsChanges.promptForSensitive.concat( settingsChanges.promptForEnv, )) { const answer = await requestSetting(setting); allSettings[setting.envVar] = answer; } const nonSensitiveSettings: Record = {}; for (const setting of settings) { const value = allSettings[setting.envVar]; if (value === undefined || value === '') { continue; } if (setting.sensitive) { await keychain.setSecret(setting.envVar, value); } else { nonSensitiveSettings[setting.envVar] = value; } } const envContent = formatEnvContent(nonSensitiveSettings); if (fsSync.existsSync(envFilePath)) { const stat = fsSync.statSync(envFilePath); if (stat.isDirectory()) { throw new Error( `Cannot write extension settings to ${envFilePath} because it is a directory.`, ); } } await fs.writeFile(envFilePath, envContent); } function formatEnvContent(settings: Record): string { let envContent = ''; for (const [key, value] of Object.entries(settings)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { throw new Error( `Invalid environment variable name: "${key}". Must contain only alphanumeric characters and underscores.`, ); } if (value.includes('\n') || value.includes('\r')) { throw new Error( `Invalid environment variable value for "${key}". Values cannot contain newlines.`, ); } const formattedValue = value.includes(' ') ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : value; envContent += `${key}=${formattedValue}\n`; } return envContent; } export async function promptForSetting( setting: ExtensionSetting, ): Promise { const response = await prompts({ type: setting.sensitive ? 'password' : 'text', name: 'value', message: `${setting.name}\n${setting.description}`, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.value; } export async function getScopedEnvContents( extensionConfig: ExtensionConfig, extensionId: string, scope: ExtensionSettingScope, workspaceDir?: string, ): Promise> { const { name: extensionName } = extensionConfig; const keychain = new KeychainTokenStorage( getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); let customEnv: Record = {}; if (fsSync.existsSync(envFilePath)) { const stat = fsSync.statSync(envFilePath); if (!stat.isDirectory()) { const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); customEnv = dotenv.parse(envFile); } } if (extensionConfig.settings) { for (const setting of extensionConfig.settings) { if (setting.sensitive) { const secret = await keychain.getSecret(setting.envVar); if (secret) { customEnv[setting.envVar] = secret; } } } } return customEnv; } export async function getEnvContents( extensionConfig: ExtensionConfig, extensionId: string, workspaceDir: string, ): Promise> { if (!extensionConfig.settings || extensionConfig.settings.length === 0) { return Promise.resolve({}); } const userSettings = await getScopedEnvContents( extensionConfig, extensionId, ExtensionSettingScope.USER, ); const workspaceSettings = await getScopedEnvContents( extensionConfig, extensionId, ExtensionSettingScope.WORKSPACE, workspaceDir, ); return { ...userSettings, ...workspaceSettings }; } export async function updateSetting( extensionConfig: ExtensionConfig, extensionId: string, settingKey: string, requestSetting: (setting: ExtensionSetting) => Promise, scope: ExtensionSettingScope, workspaceDir: string, ): Promise { const { name: extensionName, settings } = extensionConfig; if (!settings || settings.length === 0) { debugLogger.log('This extension does not have any settings.'); return; } const settingToUpdate = settings.find( (s) => s.name === settingKey || s.envVar === settingKey, ); if (!settingToUpdate) { debugLogger.log(`Setting ${settingKey} not found.`); return; } const newValue = await requestSetting(settingToUpdate); const keychain = new KeychainTokenStorage( getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); if (settingToUpdate.sensitive) { if (newValue) { await keychain.setSecret(settingToUpdate.envVar, newValue); } else { try { await keychain.deleteSecret(settingToUpdate.envVar); } catch { // Ignore if secret does not exist } } return; } // For non-sensitive settings, we need to read the existing .env file, // update the value, and write it back, preserving any other values. const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); let envContent = ''; if (fsSync.existsSync(envFilePath)) { const stat = fsSync.statSync(envFilePath); if (stat.isDirectory()) { throw new Error( `Cannot write extension settings to ${envFilePath} because it is a directory.`, ); } envContent = await fs.readFile(envFilePath, 'utf-8'); } const parsedEnv = dotenv.parse(envContent); if (!newValue) { delete parsedEnv[settingToUpdate.envVar]; } else { parsedEnv[settingToUpdate.envVar] = newValue; } // We only want to write back the variables that are not sensitive. const nonSensitiveSettings: Record = {}; const sensitiveEnvVars = new Set( settings.filter((s) => s.sensitive).map((s) => s.envVar), ); for (const [key, value] of Object.entries(parsedEnv)) { if (!sensitiveEnvVars.has(key)) { nonSensitiveSettings[key] = value; } } const newEnvContent = formatEnvContent(nonSensitiveSettings); await fs.writeFile(envFilePath, newEnvContent); } interface settingsChanges { promptForSensitive: ExtensionSetting[]; removeSensitive: ExtensionSetting[]; promptForEnv: ExtensionSetting[]; removeEnv: ExtensionSetting[]; } function getSettingsChanges( settings: ExtensionSetting[], oldSettings: ExtensionSetting[], ): settingsChanges { const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) => a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false); const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false); const sensitiveNew = settings.filter((s) => s.sensitive ?? false); const envOld = oldSettings.filter((s) => !(s.sensitive ?? false)); const envNew = settings.filter((s) => !(s.sensitive ?? false)); return { promptForSensitive: sensitiveNew.filter( (s) => !sensitiveOld.some((old) => isSameSetting(s, old)), ), removeSensitive: sensitiveOld.filter( (s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)), ), promptForEnv: envNew.filter( (s) => !envOld.some((old) => isSameSetting(s, old)), ), removeEnv: envOld.filter( (s) => !envNew.some((neu) => isSameSetting(s, neu)), ), }; } async function clearSettings( envFilePath: string, keychain: KeychainTokenStorage, ) { if (fsSync.existsSync(envFilePath)) { const stat = fsSync.statSync(envFilePath); if (!stat.isDirectory()) { await fs.writeFile(envFilePath, ''); } } if (!(await keychain.isAvailable())) { return; } const secrets = await keychain.listSecrets(); for (const secret of secrets) { await keychain.deleteSecret(secret); } return; } export async function getMissingSettings( extensionConfig: ExtensionConfig, extensionId: string, workspaceDir: string, ): Promise { const { settings } = extensionConfig; if (!settings || settings.length === 0) { return []; } const existingSettings = await getEnvContents( extensionConfig, extensionId, workspaceDir, ); const missingSettings: ExtensionSetting[] = []; for (const setting of settings) { if (existingSettings[setting.envVar] === undefined) { missingSettings.push(setting); } } return missingSettings; } ================================================ FILE: packages/cli/src/config/extensions/extensionUpdates.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import { getMissingSettings } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { debugLogger, type ExtensionInstallMetadata, type GeminiCLIExtension, coreEvents, } from '@google/gemini-cli-core'; import { ExtensionManager } from '../extension-manager.js'; import { createTestMergedSettings } from '../settings.js'; import { isWorkspaceTrusted } from '../trustedFolders.js'; // --- Mocks --- vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(), statSync: vi.fn(), lstatSync: vi.fn(), realpathSync: vi.fn((p) => p), promises: { ...actual.promises, mkdir: vi.fn(), readdir: vi.fn(), writeFile: vi.fn(), rm: vi.fn(), cp: vi.fn(), readFile: vi.fn(), lstat: vi.fn(), chmod: vi.fn(), }, }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, KeychainTokenStorage: vi.fn(), debugLogger: { warn: vi.fn(), error: vi.fn(), log: vi.fn(), }, coreEvents: { emitFeedback: vi.fn(), on: vi.fn(), off: vi.fn(), emitConsoleLog: vi.fn(), }, loadSkillsFromDir: vi.fn().mockResolvedValue([]), loadAgentsFromDirectory: vi .fn() .mockResolvedValue({ agents: [], errors: [] }), logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined), logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined), logExtensionUninstall: vi.fn().mockResolvedValue(undefined), logExtensionEnable: vi.fn().mockResolvedValue(undefined), logExtensionDisable: vi.fn().mockResolvedValue(undefined), Config: vi.fn().mockImplementation(() => ({ getEnableExtensionReloading: vi.fn().mockReturnValue(true), })), KeychainService: class { isAvailable = vi.fn().mockResolvedValue(true); getPassword = vi.fn().mockResolvedValue('test-key'); setPassword = vi.fn().mockResolvedValue(undefined); }, ExtensionIntegrityManager: class { verify = vi.fn().mockResolvedValue('verified'); store = vi.fn().mockResolvedValue(undefined); }, IntegrityDataStatus: { VERIFIED: 'verified', MISSING: 'missing', INVALID: 'invalid', }, }; }); vi.mock('./consent.js', () => ({ maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined), })); vi.mock('./extensionSettings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getEnvContents: vi.fn().mockResolvedValue({}), getMissingSettings: vi.fn(), // We will mock this implementation per test }; }); vi.mock('../trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow loadTrustedFolders: vi.fn().mockReturnValue({ setValue: vi.fn().mockResolvedValue(undefined), }), TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' }, })); // Mock ExtensionStorage to avoid real FS paths vi.mock('./storage.js', () => ({ ExtensionStorage: class { constructor(public name: string) {} getExtensionDir() { return `/mock/extensions/${this.name}`; } static getUserExtensionsDir() { return '/mock/extensions'; } static createTmpDir() { return Promise.resolve('/mock/tmp'); } }, })); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, homedir: vi.fn().mockReturnValue('/mock/home'), }; }); describe('extensionUpdates', () => { let tempWorkspaceDir: string; beforeEach(() => { vi.clearAllMocks(); // Default fs mocks vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined); vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); vi.mocked(fs.promises.readdir).mockResolvedValue([]); vi.mocked(fs.promises.lstat).mockResolvedValue({ isDirectory: () => true, mode: 0o755, } as unknown as fs.Stats); vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); vi.mocked(getMissingSettings).mockResolvedValue([]); // Allow directories to exist by default to satisfy Config/WorkspaceContext checks vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true, } as unknown as fs.Stats); vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true, } as unknown as fs.Stats); vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); tempWorkspaceDir = '/mock/workspace'; }); afterEach(() => { vi.restoreAllMocks(); }); describe('ExtensionManager integration', () => { it('should warn about missing settings after update', async () => { // 1. Setup Data const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.1.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; const installMetadata: ExtensionInstallMetadata = { source: '/mock/source', type: 'local', autoUpdate: true, }; // 2. Setup Manager const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings: createTestMergedSettings({ telemetry: { enabled: false }, experimental: { extensionConfig: true }, }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, }); // 3. Mock Internal Manager Methods vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); vi.spyOn(manager, 'getExtensions').mockReturnValue([ { name: 'test-ext', version: '1.0.0', installMetadata, path: '/mock/extensions/test-ext', contextFiles: [], mcpServers: {}, hooks: undefined, isActive: true, id: 'test-id', settings: [], resolvedSettings: [], skills: [], } as unknown as GeminiCLIExtension, ]); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); // Mock loadExtension to return something so the method doesn't crash at the end vi.spyOn(manager, 'loadExtension').mockResolvedValue({ name: 'test-ext', version: '1.1.0', } as unknown as GeminiCLIExtension); // 4. Mock External Helpers // This is the key fix: we explicitly mock `getMissingSettings` to return // the result we expect, avoiding any real FS or logic execution during the update. vi.mocked(getMissingSettings).mockResolvedValue([ { name: 's1', description: 'd1', envVar: 'VAR1', }, ]); // 5. Execute await manager.installOrUpdateExtension(installMetadata, previousConfig); // 6. Assert expect(debugLogger.warn).toHaveBeenCalledWith( expect.stringContaining( 'Extension "test-ext" has missing settings: s1', ), ); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'warning', expect.stringContaining( 'Please run "gemini extensions config test-ext [setting-name]"', ), ); }); it('should store integrity data after update', async () => { const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.1.0', }; const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', }; const installMetadata: ExtensionInstallMetadata = { source: '/mock/source', type: 'local', }; const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, settings: createTestMergedSettings(), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, }); await manager.loadExtensions(); vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); vi.spyOn(manager, 'getExtensions').mockReturnValue([ { name: 'test-ext', version: '1.0.0', installMetadata, path: '/mock/extensions/test-ext', isActive: true, } as unknown as GeminiCLIExtension, ]); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); vi.spyOn(manager, 'loadExtension').mockResolvedValue({ name: 'test-ext', version: '1.1.0', } as unknown as GeminiCLIExtension); const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity'); await manager.installOrUpdateExtension(installMetadata, previousConfig); expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/github.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { cloneFromGit, tryParseGithubUrl, fetchReleaseFromGithub, checkForExtensionUpdate, downloadFromGitHubRelease, findReleaseAsset, downloadFile, extractFile, } from './github.js'; import { simpleGit, type SimpleGit } from 'simple-git'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import * as os from 'node:os'; import * as fs from 'node:fs'; import * as https from 'node:https'; import * as tar from 'tar'; import * as extract from 'extract-zip'; import type { ExtensionManager } from '../extension-manager.js'; import { fetchJson } from './github_fetch.js'; import { EventEmitter } from 'node:events'; import type { GeminiCLIExtension, ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import type { ExtensionConfig } from '../extension.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, Storage: { getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'), getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'), }, debugLogger: { error: vi.fn(), log: vi.fn(), warn: vi.fn(), }, }; }); vi.mock('simple-git'); vi.mock('node:os'); vi.mock('node:fs'); vi.mock('node:https'); vi.mock('tar'); vi.mock('extract-zip'); vi.mock('./github_fetch.js'); vi.mock('../extension-manager.js'); // Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock vi.mock('../settings.js', () => ({ loadSettings: vi.fn(), USER_SETTINGS_PATH: '/mock/settings.json', })); describe('github.ts', () => { beforeEach(() => { vi.resetAllMocks(); }); describe('cloneFromGit', () => { let mockGit: { clone: ReturnType; getRemotes: ReturnType; fetch: ReturnType; checkout: ReturnType; listRemote: ReturnType; revparse: ReturnType; }; beforeEach(() => { mockGit = { clone: vi.fn(), getRemotes: vi.fn(), fetch: vi.fn(), checkout: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), }; vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); }); it('should clone, fetch and checkout a repo', async () => { mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); await cloneFromGit( { type: 'git', source: 'https://github.com/owner/repo.git', ref: 'v1.0.0', }, '/dest', ); expect(mockGit.clone).toHaveBeenCalledWith( 'https://github.com/owner/repo.git', './', ['--depth', '1'], ); expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0'); expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); }); it('should throw if no remotes found', async () => { mockGit.getRemotes.mockResolvedValue([]); await expect( cloneFromGit({ type: 'git', source: 'src' }, '/dest'), ).rejects.toThrow('Unable to find any remotes'); }); it('should throw on clone error', async () => { mockGit.clone.mockRejectedValue(new Error('Clone failed')); await expect( cloneFromGit({ type: 'git', source: 'src' }, '/dest'), ).rejects.toThrow('Failed to clone Git repository'); }); }); describe('tryParseGithubUrl', () => { it.each([ ['https://github.com/owner/repo', 'owner', 'repo'], ['https://github.com/owner/repo.git', 'owner', 'repo'], ['git@github.com:owner/repo.git', 'owner', 'repo'], ['owner/repo', 'owner', 'repo'], ])('should parse %s to %s/%s', (url, owner, repo) => { expect(tryParseGithubUrl(url)).toEqual({ owner, repo }); }); it.each([ 'https://gitlab.com/owner/repo', 'https://my-git-host.com/owner/group/repo', 'git@gitlab.com:some-group/some-project/some-repo.git', ])('should return null for non-GitHub URLs', (url) => { expect(tryParseGithubUrl(url)).toBeNull(); }); it('should throw for invalid formats', () => { expect(() => tryParseGithubUrl('invalid')).toThrow( 'Invalid GitHub repository source', ); }); }); describe('fetchReleaseFromGithub', () => { it('should fetch latest release if no ref provided', async () => { vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' }); await fetchReleaseFromGithub('owner', 'repo'); expect(fetchJson).toHaveBeenCalledWith( 'https://api.github.com/repos/owner/repo/releases/latest', ); }); it('should fetch specific ref if provided', async () => { vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' }); await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0'); expect(fetchJson).toHaveBeenCalledWith( 'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0', ); }); it('should handle pre-releases if allowed', async () => { vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]); const result = await fetchReleaseFromGithub( 'owner', 'repo', undefined, true, ); expect(result).toEqual({ tag_name: 'v1.0.0-beta' }); }); it('should return null if no releases found', async () => { vi.mocked(fetchJson).mockResolvedValueOnce([]); const result = await fetchReleaseFromGithub( 'owner', 'repo', undefined, true, ); expect(result).toBeNull(); }); }); describe('checkForExtensionUpdate', () => { let mockExtensionManager: ExtensionManager; let mockGit: { getRemotes: ReturnType; listRemote: ReturnType; revparse: ReturnType; }; beforeEach(() => { mockExtensionManager = { loadExtensionConfig: vi.fn(), } as unknown as ExtensionManager; mockGit = { getRemotes: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), }; vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); }); it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ version: '1.0.0', } as unknown as ExtensionConfig), ); const linkExt = { installMetadata: { type: 'link' }, } as unknown as GeminiCLIExtension; expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe( ExtensionUpdateState.NOT_UPDATABLE, ); }); it('should return UPDATE_AVAILABLE if git remote hash differs', async () => { mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'url' } }, ]); mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); mockGit.revparse.mockResolvedValue('local-hash'); const ext = { path: '/path', installMetadata: { type: 'git', source: 'url' }, } as unknown as GeminiCLIExtension; expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( ExtensionUpdateState.UPDATE_AVAILABLE, ); }); it('should return UP_TO_DATE if git remote hash matches', async () => { mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'url' } }, ]); mockGit.listRemote.mockResolvedValue('hash\tHEAD'); mockGit.revparse.mockResolvedValue('hash'); const ext = { path: '/path', installMetadata: { type: 'git', source: 'url' }, } as unknown as GeminiCLIExtension; expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( ExtensionUpdateState.UP_TO_DATE, ); }); it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation( async () => { throw new Error('Config not found'); }, ); const ext = { name: 'local-ext', version: '1.0.0', path: '/path/to/installed/ext', installMetadata: { type: 'local', source: '/path/to/source/ext' }, } as unknown as GeminiCLIExtension; expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( ExtensionUpdateState.NOT_UPDATABLE, ); }); it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => { mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'new-url' } }, ]); mockGit.listRemote.mockResolvedValue('hash\tHEAD'); mockGit.revparse.mockResolvedValue('hash'); const ext = { path: '/path', migratedTo: 'new-url', installMetadata: { type: 'git', source: 'old-url' }, } as unknown as GeminiCLIExtension; expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( ExtensionUpdateState.UPDATE_AVAILABLE, ); }); }); describe('downloadFromGitHubRelease', () => { it('should fail if no release data found', async () => { // Mock fetchJson to throw for latest release check vi.mocked(fetchJson).mockRejectedValue(new Error('Not found')); const result = await downloadFromGitHubRelease( { type: 'github-release', source: 'owner/repo', ref: 'v1', } as unknown as ExtensionInstallMetadata, '/dest', { owner: 'owner', repo: 'repo' }, ); expect(result.success).toBe(false); if (!result.success) { expect(result.failureReason).toBe('failed to fetch release data'); } }); it('should use correct headers for release assets', async () => { vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0', assets: [{ name: 'asset.tar.gz', url: 'http://asset.url' }], }); vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); // Mock https.get and fs.createWriteStream for downloadFile const mockReq = new EventEmitter(); const mockRes = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') { cb = options; } if (cb) cb(mockRes); return mockReq as unknown as import('node:http').ClientRequest; }); const mockStream = new EventEmitter() as unknown as fs.WriteStream; Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); // Mock fs.promises.readdir to return empty array (no cleanup needed) vi.mocked(fs.promises.readdir).mockResolvedValue([]); // Mock fs.promises.unlink vi.mocked(fs.promises.unlink).mockResolvedValue(undefined); const promise = downloadFromGitHubRelease( { type: 'github-release', source: 'owner/repo', ref: 'v1.0.0', } as unknown as ExtensionInstallMetadata, '/dest', { owner: 'owner', repo: 'repo' }, ); // Wait for downloadFile to be called and stream to be created await vi.waitUntil( () => vi.mocked(fs.createWriteStream).mock.calls.length > 0, ); // Trigger stream events to complete download mockRes.emit('end'); mockStream.emit('finish'); await promise; expect(https.get).toHaveBeenCalledWith( 'http://asset.url', expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/octet-stream', }), }), expect.anything(), ); }); it('should use correct headers for source tarballs', async () => { vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0', assets: [], tarball_url: 'http://tarball.url', }); // Mock https.get and fs.createWriteStream for downloadFile const mockReq = new EventEmitter(); const mockRes = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') { cb = options; } if (cb) cb(mockRes); return mockReq as unknown as import('node:http').ClientRequest; }); const mockStream = new EventEmitter() as unknown as fs.WriteStream; Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); // Mock fs.promises.readdir to return empty array vi.mocked(fs.promises.readdir).mockResolvedValue([]); // Mock fs.promises.unlink vi.mocked(fs.promises.unlink).mockResolvedValue(undefined); const promise = downloadFromGitHubRelease( { type: 'github-release', source: 'owner/repo', ref: 'v1.0.0', } as unknown as ExtensionInstallMetadata, '/dest', { owner: 'owner', repo: 'repo' }, ); // Wait for downloadFile to be called and stream to be created await vi.waitUntil( () => vi.mocked(fs.createWriteStream).mock.calls.length > 0, ); // Trigger stream events to complete download mockRes.emit('end'); mockStream.emit('finish'); await promise; expect(https.get).toHaveBeenCalledWith( 'http://tarball.url', expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/vnd.github+json', }), }), expect.anything(), ); }); }); describe('findReleaseAsset', () => { it('should find platform/arch specific asset', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.mocked(os.arch).mockReturnValue('arm64'); const assets = [ { name: 'darwin.arm64.tar.gz', url: 'url1' }, { name: 'linux.x64.tar.gz', url: 'url2' }, ]; expect(findReleaseAsset(assets)).toEqual(assets[0]); }); it('should find generic asset', () => { vi.mocked(os.platform).mockReturnValue('darwin'); const assets = [{ name: 'generic.tar.gz', url: 'url' }]; expect(findReleaseAsset(assets)).toEqual(assets[0]); }); }); describe('downloadFile', () => { it('should download file successfully', async () => { const mockReq = new EventEmitter(); const mockRes = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') { cb = options; } if (cb) cb(mockRes); return mockReq as unknown as import('node:http').ClientRequest; }); const mockStream = new EventEmitter() as unknown as fs.WriteStream; Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); const promise = downloadFile('url', '/dest'); mockRes.emit('end'); mockStream.emit('finish'); await expect(promise).resolves.toBeUndefined(); }); it('should fail on non-200 status', async () => { const mockReq = new EventEmitter(); const mockRes = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockRes, { statusCode: 404 }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') { cb = options; } if (cb) cb(mockRes); return mockReq as unknown as import('node:http').ClientRequest; }); await expect(downloadFile('url', '/dest')).rejects.toThrow( 'Request failed with status code 404', ); }); it('should follow redirects', async () => { const mockReq = new EventEmitter(); const mockResRedirect = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockResRedirect, { statusCode: 302, headers: { location: 'new-url' }, }); const mockResSuccess = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockResSuccess, { statusCode: 200, pipe: vi.fn() }); vi.mocked(https.get) .mockImplementationOnce((url, options, cb) => { if (typeof options === 'function') cb = options; if (cb) cb(mockResRedirect); return mockReq as unknown as import('node:http').ClientRequest; }) .mockImplementationOnce((url, options, cb) => { if (typeof options === 'function') cb = options; if (cb) cb(mockResSuccess); return mockReq as unknown as import('node:http').ClientRequest; }); const mockStream = new EventEmitter() as unknown as fs.WriteStream; Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); const promise = downloadFile('url', '/dest'); mockResSuccess.emit('end'); mockStream.emit('finish'); await expect(promise).resolves.toBeUndefined(); expect(https.get).toHaveBeenCalledTimes(2); expect(https.get).toHaveBeenLastCalledWith( 'new-url', expect.anything(), expect.anything(), ); }); it('should fail after too many redirects', async () => { const mockReq = new EventEmitter(); const mockResRedirect = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockResRedirect, { statusCode: 302, headers: { location: 'new-url' }, }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') cb = options; if (cb) cb(mockResRedirect); return mockReq as unknown as import('node:http').ClientRequest; }); await expect(downloadFile('url', '/dest')).rejects.toThrow( 'Too many redirects', ); }, 10000); // Increase timeout for this test if needed, though with mocks it should be fast it('should fail if redirect location is missing', async () => { const mockReq = new EventEmitter(); const mockResRedirect = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockResRedirect, { statusCode: 302, headers: {}, // No location }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') cb = options; if (cb) cb(mockResRedirect); return mockReq as unknown as import('node:http').ClientRequest; }); await expect(downloadFile('url', '/dest')).rejects.toThrow( 'Redirect response missing Location header', ); }); it('should pass custom headers', async () => { const mockReq = new EventEmitter(); const mockRes = new EventEmitter() as unknown as import('node:http').IncomingMessage; Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); vi.mocked(https.get).mockImplementation((url, options, cb) => { if (typeof options === 'function') cb = options; if (cb) cb(mockRes); return mockReq as unknown as import('node:http').ClientRequest; }); const mockStream = new EventEmitter() as unknown as fs.WriteStream; Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); const promise = downloadFile('url', '/dest', { headers: { 'X-Custom': 'value' }, }); mockRes.emit('end'); mockStream.emit('finish'); await expect(promise).resolves.toBeUndefined(); expect(https.get).toHaveBeenCalledWith( 'url', expect.objectContaining({ headers: expect.objectContaining({ 'X-Custom': 'value' }), }), expect.anything(), ); }); }); describe('extractFile', () => { it('should extract tar.gz using tar', async () => { await extractFile('file.tar.gz', '/dest'); expect(tar.x).toHaveBeenCalled(); }); it('should extract zip using extract-zip', async () => { vi.mocked(extract.default || extract).mockResolvedValue(undefined); await extractFile('file.zip', '/dest'); // Check if extract was called. Note: extract-zip export might be default or named depending on mock }); it('should throw for unsupported extensions', async () => { await expect(extractFile('file.txt', '/dest')).rejects.toThrow( 'Unsupported file extension', ); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/github.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { simpleGit } from 'simple-git'; import { debugLogger, getErrorMessage, type ExtensionInstallMetadata, type GeminiCLIExtension, } from '@google/gemini-cli-core'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import * as os from 'node:os'; import * as https from 'node:https'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as tar from 'tar'; import extract from 'extract-zip'; import { fetchJson, getGitHubToken } from './github_fetch.js'; import type { ExtensionConfig } from '../extension.js'; import type { ExtensionManager } from '../extension-manager.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; /** * Clones a Git repository to a specified local path. * @param installMetadata The metadata for the extension to install. * @param destination The destination path to clone the repository to. */ export async function cloneFromGit( installMetadata: ExtensionInstallMetadata, destination: string, ): Promise { try { const git = simpleGit(destination); let sourceUrl = installMetadata.source; const token = getGitHubToken(); if (token) { try { const parsedUrl = new URL(sourceUrl); if ( parsedUrl.protocol === 'https:' && parsedUrl.hostname === 'github.com' ) { if (!parsedUrl.username) { parsedUrl.username = token; } sourceUrl = parsedUrl.toString(); } } catch { // If source is not a valid URL, we don't inject the token. // We let git handle the source as is. } } await git.clone(sourceUrl, './', ['--depth', '1']); const remotes = await git.getRemotes(true); if (remotes.length === 0) { throw new Error( `Unable to find any remotes for repo ${installMetadata.source}`, ); } const refToFetch = installMetadata.ref || 'HEAD'; await git.fetch(remotes[0].name, refToFetch); // After fetching, checkout FETCH_HEAD to get the content of the fetched ref. // This results in a detached HEAD state, which is fine for this purpose. await git.checkout('FETCH_HEAD'); } catch (error) { throw new Error( `Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`, { cause: error, }, ); } } export interface GithubRepoInfo { owner: string; repo: string; } export function tryParseGithubUrl(source: string): GithubRepoInfo | null { // Handle SCP-style SSH URLs. if (source.startsWith('git@')) { if (source.startsWith('git@github.com:')) { // It's a GitHub SSH URL, so normalize it for the URL parser. source = source.replace('git@github.com:', ''); } else { // It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo. return null; } } // Default to a github repo path, so `source` can be just an org/repo let parsedUrl: URL; try { // Use the standard URL constructor for backward compatibility. parsedUrl = new URL(source, 'https://github.com'); } catch (e) { // Throw a TypeError to maintain a consistent error contract for invalid URLs. // This avoids a breaking change for consumers who might expect a TypeError. throw new TypeError(`Invalid repo URL: ${source}`, { cause: e }); } if (!parsedUrl) { throw new Error(`Invalid repo URL: ${source}`); } if (parsedUrl?.host !== 'github.com') { return null; } // The pathname should be "/owner/repo". const parts = parsedUrl?.pathname .split('/') // Remove the empty segments, fixes trailing and leading slashes .filter((part) => part !== ''); if (parts?.length !== 2) { throw new Error( `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, ); } const owner = parts[0]; const repo = parts[1].replace('.git', ''); return { owner, repo, }; } export async function fetchReleaseFromGithub( owner: string, repo: string, ref?: string, allowPreRelease?: boolean, ): Promise { if (ref) { return fetchJson( `https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`, ); } if (!allowPreRelease) { // Grab the release that is tagged as the "latest", github does not allow // this to be a pre-release so we can blindly grab it. try { return await fetchJson( `https://api.github.com/repos/${owner}/${repo}/releases/latest`, ); } catch (_) { // This can fail if there is no release marked latest. In that case // we want to just try the pre-release logic below. } } // If pre-releases are allowed, we just grab the most recent release. const releases = await fetchJson( `https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`, ); if (releases.length === 0) { return null; } return releases[0]; } export async function checkForExtensionUpdate( extension: GeminiCLIExtension, extensionManager: ExtensionManager, ): Promise { const installMetadata = extension.installMetadata; if (installMetadata?.type === 'local') { let latestConfig: ExtensionConfig | undefined; try { latestConfig = await extensionManager.loadExtensionConfig( installMetadata.source, ); } catch (e) { debugLogger.warn( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}. Error: ${getErrorMessage(e)}`, ); return ExtensionUpdateState.NOT_UPDATABLE; } if (!latestConfig) { debugLogger.warn( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, ); return ExtensionUpdateState.NOT_UPDATABLE; } if (latestConfig.version !== extension.version) { return ExtensionUpdateState.UPDATE_AVAILABLE; } return ExtensionUpdateState.UP_TO_DATE; } if ( !installMetadata || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) { return ExtensionUpdateState.NOT_UPDATABLE; } if (extension.migratedTo) { const migratedState = await checkForExtensionUpdate( { ...extension, installMetadata: { ...installMetadata, source: extension.migratedTo }, migratedTo: undefined, }, extensionManager, ); if ( migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || migratedState === ExtensionUpdateState.UP_TO_DATE ) { return ExtensionUpdateState.UPDATE_AVAILABLE; } } try { if (installMetadata.type === 'git') { const git = simpleGit(extension.path); const remotes = await git.getRemotes(true); if (remotes.length === 0) { debugLogger.error('No git remotes found.'); return ExtensionUpdateState.ERROR; } const remoteUrl = remotes[0].refs.fetch; if (!remoteUrl) { debugLogger.error( `No fetch URL found for git remote ${remotes[0].name}.`, ); return ExtensionUpdateState.ERROR; } // Determine the ref to check on the remote. const refToCheck = installMetadata.ref || 'HEAD'; const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { debugLogger.error(`Git ref ${refToCheck} not found.`); return ExtensionUpdateState.ERROR; } const remoteHash = lsRemoteOutput.split('\t')[0]; const localHash = await git.revparse(['HEAD']); if (!remoteHash) { debugLogger.error( `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, ); return ExtensionUpdateState.ERROR; } if (remoteHash === localHash) { return ExtensionUpdateState.UP_TO_DATE; } return ExtensionUpdateState.UPDATE_AVAILABLE; } else { const { source, releaseTag } = installMetadata; if (!source) { debugLogger.error(`No "source" provided for extension.`); return ExtensionUpdateState.ERROR; } const repoInfo = tryParseGithubUrl(source); if (!repoInfo) { debugLogger.error( `Source is not a valid GitHub repository for release checks: ${source}`, ); return ExtensionUpdateState.ERROR; } const { owner, repo } = repoInfo; const releaseData = await fetchReleaseFromGithub( owner, repo, installMetadata.ref, installMetadata.allowPreRelease, ); if (!releaseData) { return ExtensionUpdateState.ERROR; } if (releaseData.tag_name !== releaseTag) { return ExtensionUpdateState.UPDATE_AVAILABLE; } return ExtensionUpdateState.UP_TO_DATE; } } catch (error) { debugLogger.error( `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, ); return ExtensionUpdateState.ERROR; } } export type GitHubDownloadResult = | { tagName?: string; type: 'git' | 'github-release'; success: false; failureReason: | 'failed to fetch release data' | 'no release data' | 'no release asset found' | 'failed to download asset' | 'failed to extract asset' | 'unknown'; errorMessage: string; } | { tagName?: string; type: 'git' | 'github-release'; success: true; }; export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, githubRepoInfo: GithubRepoInfo, ): Promise { const { ref, allowPreRelease: preRelease } = installMetadata; const { owner, repo } = githubRepoInfo; let releaseData: GithubReleaseData | null = null; try { try { releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease); if (!releaseData) { return { failureReason: 'no release data', success: false, type: 'github-release', errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`, }; } } catch (error) { return { failureReason: 'failed to fetch release data', success: false, type: 'github-release', errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`, }; } const asset = findReleaseAsset(releaseData.assets); let archiveUrl: string | undefined; let isTar = false; let isZip = false; let fileName: string | undefined; if (asset) { archiveUrl = asset.url; fileName = asset.name; } else { if (releaseData.tarball_url) { archiveUrl = releaseData.tarball_url; isTar = true; } else if (releaseData.zipball_url) { archiveUrl = releaseData.zipball_url; isZip = true; } } if (!archiveUrl) { return { failureReason: 'no release asset found', success: false, type: 'github-release', tagName: releaseData.tag_name, errorMessage: `No assets found for release with tag ${releaseData.tag_name}`, }; } if (!fileName) { fileName = path.basename(new URL(archiveUrl).pathname); } let downloadedAssetPath = path.join(destination, fileName); if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) { downloadedAssetPath += '.tar.gz'; } else if (isZip && !downloadedAssetPath.endsWith('.zip')) { downloadedAssetPath += '.zip'; } try { // GitHub API requires different Accept headers for different types of downloads: // 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content. // 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return // a 302 Redirect to the actual download location (codeload.github.com). // Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error. const headers = { ...(asset ? { Accept: 'application/octet-stream' } : { Accept: 'application/vnd.github+json' }), }; await downloadFile(archiveUrl, downloadedAssetPath, { headers }); } catch (error) { return { failureReason: 'failed to download asset', success: false, type: 'github-release', tagName: releaseData.tag_name, errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`, }; } try { await extractFile(downloadedAssetPath, destination); } catch (error) { return { failureReason: 'failed to extract asset', success: false, type: 'github-release', tagName: releaseData.tag_name, errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`, }; } // For regular github releases, the repository is put inside of a top level // directory. In this case we should see exactly two file in the destination // dir, the archive and the directory. If we see that, validate that the // dir has a gemini extension configuration file and then move all files // from the directory up one level into the destination directory. const entries = await fs.promises.readdir(destination, { withFileTypes: true, }); if (entries.length === 2) { const lonelyDir = entries.find((entry) => entry.isDirectory()); if ( lonelyDir && fs.existsSync( path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME), ) ) { const dirPathToExtract = path.join(destination, lonelyDir.name); const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); for (const file of extractedDirFiles) { await fs.promises.rename( path.join(dirPathToExtract, file), path.join(destination, file), ); } await fs.promises.rmdir(dirPathToExtract); } } await fs.promises.unlink(downloadedAssetPath); return { tagName: releaseData.tag_name, type: 'github-release', success: true, }; } catch (error) { return { failureReason: 'unknown', success: false, type: 'github-release', tagName: releaseData?.tag_name, errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, }; } } interface GithubReleaseData { assets: Asset[]; tag_name: string; tarball_url?: string; zipball_url?: string; } interface Asset { name: string; url: string; } export function findReleaseAsset(assets: Asset[]): Asset | undefined { const platform = os.platform(); const arch = os.arch(); const platformArchPrefix = `${platform}.${arch}.`; const platformPrefix = `${platform}.`; // Check for platform + architecture specific asset const platformArchAsset = assets.find((asset) => asset.name.toLowerCase().startsWith(platformArchPrefix), ); if (platformArchAsset) { return platformArchAsset; } // Check for platform specific asset const platformAsset = assets.find((asset) => asset.name.toLowerCase().startsWith(platformPrefix), ); if (platformAsset) { return platformAsset; } // Check for generic asset if only one is available const genericAsset = assets.find( (asset) => !asset.name.toLowerCase().includes('darwin') && !asset.name.toLowerCase().includes('linux') && !asset.name.toLowerCase().includes('win32'), ); if (assets.length === 1) { return genericAsset; } return undefined; } export interface DownloadOptions { headers?: Record; } export async function downloadFile( url: string, dest: string, options?: DownloadOptions, redirectCount: number = 0, ): Promise { const headers: Record = { 'User-agent': 'gemini-cli', Accept: 'application/octet-stream', ...options?.headers, }; const token = getGitHubToken(); if (token) { headers['Authorization'] = `token ${token}`; } return new Promise((resolve, reject) => { https .get(url, { headers }, (res) => { if (res.statusCode === 302 || res.statusCode === 301) { if (redirectCount >= 10) { return reject(new Error('Too many redirects')); } if (!res.headers.location) { return reject( new Error('Redirect response missing Location header'), ); } downloadFile(res.headers.location, dest, options, redirectCount + 1) .then(resolve) .catch(reject); return; } if (res.statusCode !== 200) { return reject( new Error(`Request failed with status code ${res.statusCode}`), ); } const file = fs.createWriteStream(dest); res.pipe(file); file.on('finish', () => file.close(resolve as () => void)); }) .on('error', reject); }); } export async function extractFile(file: string, dest: string): Promise { if (file.endsWith('.tar.gz')) { await tar.x({ file, cwd: dest, }); } else if (file.endsWith('.zip')) { await extract(file, { dir: dest }); } else { throw new Error(`Unsupported file extension for extraction: ${file}`); } } ================================================ FILE: packages/cli/src/config/extensions/github_fetch.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import * as https from 'node:https'; import { EventEmitter } from 'node:events'; import { fetchJson, getGitHubToken } from './github_fetch.js'; import type { ClientRequest, IncomingMessage } from 'node:http'; vi.mock('node:https'); describe('getGitHubToken', () => { const originalToken = process.env['GITHUB_TOKEN']; afterEach(() => { if (originalToken) { process.env['GITHUB_TOKEN'] = originalToken; } else { delete process.env['GITHUB_TOKEN']; } }); it('should return the token if GITHUB_TOKEN is set', () => { process.env['GITHUB_TOKEN'] = 'test-token'; expect(getGitHubToken()).toBe('test-token'); }); it('should return undefined if GITHUB_TOKEN is not set', () => { delete process.env['GITHUB_TOKEN']; expect(getGitHubToken()).toBeUndefined(); }); }); describe('fetchJson', () => { const getMock = vi.mocked(https.get); afterEach(() => { vi.resetAllMocks(); }); it('should fetch and parse JSON successfully', async () => { getMock.mockImplementationOnce((_url, _options, callback) => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 200; (callback as (res: IncomingMessage) => void)(res); res.emit('data', Buffer.from('{"foo":')); res.emit('data', Buffer.from('"bar"}')); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({ foo: 'bar', }); }); it('should handle redirects (301 and 302)', async () => { // Test 302 getMock.mockImplementationOnce((_url, _options, callback) => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 302; res.headers = { location: 'https://example.com/final' }; (callback as (res: IncomingMessage) => void)(res); res.emit('end'); return new EventEmitter() as ClientRequest; }); getMock.mockImplementationOnce((url, _options, callback) => { expect(url).toBe('https://example.com/final'); const res = new EventEmitter() as IncomingMessage; res.statusCode = 200; (callback as (res: IncomingMessage) => void)(res); res.emit('data', Buffer.from('{"success": true}')); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({ success: true, }); // Test 301 getMock.mockImplementationOnce((_url, _options, callback) => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 301; res.headers = { location: 'https://example.com/final-permanent' }; (callback as (res: IncomingMessage) => void)(res); res.emit('end'); return new EventEmitter() as ClientRequest; }); getMock.mockImplementationOnce((url, _options, callback) => { expect(url).toBe('https://example.com/final-permanent'); const res = new EventEmitter() as IncomingMessage; res.statusCode = 200; (callback as (res: IncomingMessage) => void)(res); res.emit('data', Buffer.from('{"permanent": true}')); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect( fetchJson('https://example.com/redirect-perm'), ).resolves.toEqual({ permanent: true }); }); it('should reject on non-200/30x status code', async () => { getMock.mockImplementationOnce((_url, _options, callback) => { const res = new EventEmitter() as IncomingMessage; res.statusCode = 404; (callback as (res: IncomingMessage) => void)(res); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect(fetchJson('https://example.com/error')).rejects.toThrow( 'Request failed with status code 404', ); }); it('should reject on request error', async () => { const error = new Error('Network error'); getMock.mockImplementationOnce(() => { const req = new EventEmitter() as ClientRequest; req.emit('error', error); return req; }); await expect(fetchJson('https://example.com/error')).rejects.toThrow( 'Network error', ); }); describe('with GITHUB_TOKEN', () => { const originalToken = process.env['GITHUB_TOKEN']; beforeEach(() => { process.env['GITHUB_TOKEN'] = 'my-secret-token'; }); afterEach(() => { if (originalToken) { process.env['GITHUB_TOKEN'] = originalToken; } else { delete process.env['GITHUB_TOKEN']; } }); it('should include Authorization header if token is present', async () => { getMock.mockImplementationOnce((_url, options, callback) => { expect(options.headers).toEqual({ 'User-Agent': 'gemini-cli', Authorization: 'token my-secret-token', }); const res = new EventEmitter() as IncomingMessage; res.statusCode = 200; (callback as (res: IncomingMessage) => void)(res); res.emit('data', Buffer.from('{"foo": "bar"}')); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ foo: 'bar', }); }); }); describe('without GITHUB_TOKEN', () => { const originalToken = process.env['GITHUB_TOKEN']; beforeEach(() => { delete process.env['GITHUB_TOKEN']; }); afterEach(() => { if (originalToken) { process.env['GITHUB_TOKEN'] = originalToken; } }); it('should not include Authorization header if token is not present', async () => { getMock.mockImplementationOnce((_url, options, callback) => { expect(options.headers).toEqual({ 'User-Agent': 'gemini-cli', }); const res = new EventEmitter() as IncomingMessage; res.statusCode = 200; (callback as (res: IncomingMessage) => void)(res); res.emit('data', Buffer.from('{"foo": "bar"}')); res.emit('end'); return new EventEmitter() as ClientRequest; }); await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ foo: 'bar', }); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/github_fetch.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as https from 'node:https'; export function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } export async function fetchJson( url: string, redirectCount: number = 0, ): Promise { const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'gemini-cli', }; const token = getGitHubToken(); if (token) { headers.Authorization = `token ${token}`; } return new Promise((resolve, reject) => { https .get(url, { headers }, (res) => { if (res.statusCode === 302 || res.statusCode === 301) { if (redirectCount >= 10) { return reject(new Error('Too many redirects')); } if (!res.headers.location) { return reject(new Error('No location header in redirect response')); } fetchJson(res.headers.location, redirectCount++) .then(resolve) .catch(reject); return; } if (res.statusCode !== 200) { return reject( new Error(`Request failed with status code ${res.statusCode}`), ); } const chunks: Buffer[] = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const data = Buffer.concat(chunks).toString(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion resolve(JSON.parse(data) as T); }); }) .on('error', reject); }); } ================================================ FILE: packages/cli/src/config/extensions/storage.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ExtensionStorage } from './storage.js'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, } from './variables.js'; import { Storage } from '@google/gemini-cli-core'; vi.mock('node:os'); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promises: { ...actual.promises, mkdtemp: vi.fn(), }, }; }); vi.mock('@google/gemini-cli-core'); describe('ExtensionStorage', () => { const mockHomeDir = '/mock/home'; const extensionName = 'test-extension'; let storage: ExtensionStorage; beforeEach(() => { vi.mocked(os.homedir).mockReturnValue(mockHomeDir); vi.mocked(Storage).mockImplementation( () => ({ getExtensionsDir: () => path.join(mockHomeDir, '.gemini', 'extensions'), }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any ); storage = new ExtensionStorage(extensionName); }); afterEach(() => { vi.restoreAllMocks(); }); it('should return the correct extension directory', () => { const expectedDir = path.join( mockHomeDir, '.gemini', 'extensions', extensionName, ); expect(storage.getExtensionDir()).toBe(expectedDir); }); it('should return the correct config path', () => { const expectedPath = path.join( mockHomeDir, '.gemini', 'extensions', extensionName, EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME ); expect(storage.getConfigPath()).toBe(expectedPath); }); it('should return the correct env file path', () => { const expectedPath = path.join( mockHomeDir, '.gemini', 'extensions', extensionName, EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME ); expect(storage.getEnvFilePath()).toBe(expectedPath); }); it('should return the correct user extensions directory', () => { const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions'); expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir); }); it('should create a temporary directory', async () => { const mockTmpDir = '/tmp/gemini-extension-123'; vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); const result = await ExtensionStorage.createTmpDir(); expect(fs.promises.mkdtemp).toHaveBeenCalledWith( path.join('/tmp', 'gemini-extension'), ); expect(result).toBe(mockTmpDir); }); }); ================================================ FILE: packages/cli/src/config/extensions/storage.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, } from './variables.js'; import { Storage, homedir } from '@google/gemini-cli-core'; export class ExtensionStorage { private readonly extensionName: string; constructor(extensionName: string) { this.extensionName = extensionName; } getExtensionDir(): string { return path.join( ExtensionStorage.getUserExtensionsDir(), this.extensionName, ); } getConfigPath(): string { return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); } getEnvFilePath(): string { return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME); } static getUserExtensionsDir(): string { return new Storage(homedir()).getExtensionsDir(); } static async createTmpDir(): Promise { return fs.promises.mkdtemp(path.join(os.tmpdir(), 'gemini-extension')); } } ================================================ FILE: packages/cli/src/config/extensions/update.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { updateExtension, updateAllUpdatableExtensions, checkForAllExtensionUpdates, } from './update.js'; import { ExtensionUpdateState, type ExtensionUpdateStatus, } from '../../ui/state/extensions.js'; import { ExtensionStorage } from './storage.js'; import { type ExtensionManager, copyExtension } from '../extension-manager.js'; import { checkForExtensionUpdate } from './github.js'; import { loadInstallMetadata } from '../extension.js'; import * as fs from 'node:fs'; import { type GeminiCLIExtension, type ExtensionInstallMetadata, IntegrityDataStatus, } from '@google/gemini-cli-core'; vi.mock('./storage.js', () => ({ ExtensionStorage: { createTmpDir: vi.fn(), }, })); vi.mock('../extension-manager.js', () => ({ copyExtension: vi.fn(), // We don't need to mock the class implementation if we pass a mock instance })); vi.mock('./github.js', () => ({ checkForExtensionUpdate: vi.fn(), })); vi.mock('../extension.js', () => ({ loadInstallMetadata: vi.fn(), })); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promises: { ...actual.promises, rm: vi.fn(), }, }; }); describe('Extension Update Logic', () => { let mockExtensionManager: ExtensionManager; let mockDispatch: ReturnType; const mockExtension: GeminiCLIExtension = { name: 'test-extension', version: '1.0.0', path: '/path/to/extension', } as GeminiCLIExtension; beforeEach(() => { vi.clearAllMocks(); mockExtensionManager = { loadExtensionConfig: vi.fn().mockResolvedValue({ name: 'test-extension', version: '1.0.0', }), installOrUpdateExtension: vi.fn().mockResolvedValue({ ...mockExtension, version: '1.1.0', }), verifyExtensionIntegrity: vi .fn() .mockResolvedValue(IntegrityDataStatus.VERIFIED), storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), } as unknown as ExtensionManager; mockDispatch = vi.fn(); // Default mock behaviors vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir'); vi.mocked(loadInstallMetadata).mockReturnValue({ source: 'https://example.com/repo.git', type: 'git', }); }); describe('updateExtension', () => { it('should return undefined if state is already UPDATING', async () => { const result = await updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATING, mockDispatch, ); expect(result).toBeUndefined(); expect(mockDispatch).not.toHaveBeenCalled(); }); it('should throw error and set state to ERROR if install metadata type is unknown', async () => { vi.mocked(loadInstallMetadata).mockReturnValue({ type: undefined, } as unknown as ExtensionInstallMetadata); await expect( updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ), ).rejects.toThrow('type is unknown'); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UPDATING, }, }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.ERROR, }, }); }); it('should throw error and set state to UP_TO_DATE if extension is linked', async () => { vi.mocked(loadInstallMetadata).mockReturnValue({ type: 'link', source: '', }); await expect( updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ), ).rejects.toThrow('Extension is linked'); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UP_TO_DATE, }, }); }); it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ name: 'test-extension', version: '1.0.0', }), ); vi.mocked( mockExtensionManager.installOrUpdateExtension, ).mockResolvedValue({ ...mockExtension, version: '1.1.0', }); const result = await updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ); expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, }, }); expect(result).toEqual({ name: 'test-extension', originalVersion: '1.0.0', updatedVersion: '1.1.0', }); expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', { recursive: true, force: true, }); }); it('should migrate source if migratedTo is set and an update is available', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ name: 'test-extension', version: '1.0.0', }), ); vi.mocked( mockExtensionManager.installOrUpdateExtension, ).mockResolvedValue({ ...mockExtension, version: '1.1.0', }); vi.mocked(checkForExtensionUpdate).mockResolvedValue( ExtensionUpdateState.UPDATE_AVAILABLE, ); const extensionWithMigratedTo = { ...mockExtension, migratedTo: 'https://new-source.com/repo.git', }; await updateExtension( extensionWithMigratedTo, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ); expect(checkForExtensionUpdate).toHaveBeenCalledWith( expect.objectContaining({ installMetadata: expect.objectContaining({ source: 'https://new-source.com/repo.git', }), }), mockExtensionManager, ); expect( mockExtensionManager.installOrUpdateExtension, ).toHaveBeenCalledWith( expect.objectContaining({ source: 'https://new-source.com/repo.git', }), expect.anything(), ); }); it('should set state to UPDATED if enableExtensionReloading is true', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ name: 'test-extension', version: '1.0.0', }), ); vi.mocked( mockExtensionManager.installOrUpdateExtension, ).mockResolvedValue({ ...mockExtension, version: '1.1.0', }); await updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, true, // enableExtensionReloading ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UPDATED, }, }); }); it('should rollback and set state to ERROR if installation fails', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ name: 'test-extension', version: '1.0.0', }), ); vi.mocked( mockExtensionManager.installOrUpdateExtension, ).mockRejectedValue(new Error('Install failed')); await expect( updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ), ).rejects.toThrow('Updated extension not found after installation'); expect(copyExtension).toHaveBeenCalledWith( '/tmp/mock-dir', mockExtension.path, ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.ERROR, }, }); expect(fs.promises.rm).toHaveBeenCalled(); }); describe('Integrity Verification', () => { it('should fail update with security alert if integrity is invalid', async () => { vi.mocked( mockExtensionManager.verifyExtensionIntegrity, ).mockResolvedValue(IntegrityDataStatus.INVALID); await expect( updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ), ).rejects.toThrow( 'Extension test-extension cannot be updated. Extension integrity cannot be verified.', ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.ERROR, }, }); }); it('should establish trust on first update if integrity data is missing', async () => { vi.mocked( mockExtensionManager.verifyExtensionIntegrity, ).mockResolvedValue(IntegrityDataStatus.MISSING); await updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ); // Verify updateExtension delegates to installOrUpdateExtension, // which is responsible for establishing trust internally. expect( mockExtensionManager.installOrUpdateExtension, ).toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, }, }); }); it('should throw if integrity manager throws', async () => { vi.mocked( mockExtensionManager.verifyExtensionIntegrity, ).mockRejectedValue(new Error('Verification failed')); await expect( updateExtension( mockExtension, mockExtensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, mockDispatch, ), ).rejects.toThrow( 'Extension test-extension cannot be updated. Verification failed', ); }); }); }); describe('updateAllUpdatableExtensions', () => { it('should update all extensions with UPDATE_AVAILABLE status', async () => { const extensions: GeminiCLIExtension[] = [ { ...mockExtension, name: 'ext1' }, { ...mockExtension, name: 'ext2' }, { ...mockExtension, name: 'ext3' }, ]; const extensionsState = new Map([ ['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }], ['ext2', { status: ExtensionUpdateState.UP_TO_DATE }], ['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }], ]); vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ name: 'ext', version: '1.0.0', }), ); vi.mocked( mockExtensionManager.installOrUpdateExtension, ).mockResolvedValue({ ...mockExtension, version: '1.1.0' }); const results = await updateAllUpdatableExtensions( extensions, extensionsState as Map, mockExtensionManager, mockDispatch, ); expect(results).toHaveLength(2); expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']); expect( mockExtensionManager.installOrUpdateExtension, ).toHaveBeenCalledTimes(2); }); }); describe('checkForAllExtensionUpdates', () => { it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => { await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch); expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' }); }); it('should set state to NOT_UPDATABLE if no install metadata', async () => { const extensions: GeminiCLIExtension[] = [ { ...mockExtension, installMetadata: undefined }, ]; await checkForAllExtensionUpdates( extensions, mockExtensionManager, mockDispatch, ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.NOT_UPDATABLE, }, }); }); it('should check for updates and update state', async () => { const extensions: GeminiCLIExtension[] = [ { ...mockExtension, installMetadata: { type: 'git', source: '...' } }, ]; vi.mocked(checkForExtensionUpdate).mockResolvedValue( ExtensionUpdateState.UPDATE_AVAILABLE, ); await checkForAllExtensionUpdates( extensions, mockExtensionManager, mockDispatch, ); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.CHECKING_FOR_UPDATES, }, }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_STATE', payload: { name: mockExtension.name, state: ExtensionUpdateState.UPDATE_AVAILABLE, }, }); }); }); }); ================================================ FILE: packages/cli/src/config/extensions/update.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type ExtensionUpdateAction, ExtensionUpdateState, type ExtensionUpdateStatus, } from '../../ui/state/extensions.js'; import { loadInstallMetadata } from '../extension.js'; import { checkForExtensionUpdate } from './github.js'; import { debugLogger, getErrorMessage, type GeminiCLIExtension, IntegrityDataStatus, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import { copyExtension, type ExtensionManager } from '../extension-manager.js'; import { ExtensionStorage } from './storage.js'; export interface ExtensionUpdateInfo { name: string; originalVersion: string; updatedVersion: string; } export async function updateExtension( extension: GeminiCLIExtension, extensionManager: ExtensionManager, currentState: ExtensionUpdateState, dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void, enableExtensionReloading?: boolean, ): Promise { if (currentState === ExtensionUpdateState.UPDATING) { return undefined; } dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.UPDATING }, }); const installMetadata = loadInstallMetadata(extension.path); if (!installMetadata?.type) { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); throw new Error( `Extension ${extension.name} cannot be updated, type is unknown.`, ); } try { const status = await extensionManager.verifyExtensionIntegrity( extension.name, installMetadata, ); if (status === IntegrityDataStatus.INVALID) { throw new Error('Extension integrity cannot be verified'); } } catch (e) { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); throw new Error( `Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`, ); } if (installMetadata?.type === 'link') { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE }, }); throw new Error(`Extension is linked so does not need to be updated`); } if (extension.migratedTo) { const migratedState = await checkForExtensionUpdate( { ...extension, installMetadata: { ...installMetadata, source: extension.migratedTo }, migratedTo: undefined, }, extensionManager, ); if ( migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || migratedState === ExtensionUpdateState.UP_TO_DATE ) { installMetadata.source = extension.migratedTo; } } const originalVersion = extension.version; const tempDir = await ExtensionStorage.createTmpDir(); try { const previousExtensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); let updatedExtension: GeminiCLIExtension; try { updatedExtension = await extensionManager.installOrUpdateExtension( installMetadata, previousExtensionConfig, ); } catch (e) { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); throw new Error( `Updated extension not found after installation, got error:\n${e}`, ); } const updatedVersion = updatedExtension.version; dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: enableExtensionReloading ? ExtensionUpdateState.UPDATED : ExtensionUpdateState.UPDATED_NEEDS_RESTART, }, }); return { name: extension.name, originalVersion, updatedVersion, }; } catch (e) { debugLogger.error( `Error updating extension, rolling back. ${getErrorMessage(e)}`, ); dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); await copyExtension(tempDir, extension.path); throw e; } finally { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } export async function updateAllUpdatableExtensions( extensions: GeminiCLIExtension[], extensionsState: Map, extensionManager: ExtensionManager, dispatch: (action: ExtensionUpdateAction) => void, enableExtensionReloading?: boolean, ): Promise { return ( await Promise.all( extensions .filter( (extension) => extensionsState.get(extension.name)?.status === ExtensionUpdateState.UPDATE_AVAILABLE, ) .map((extension) => updateExtension( extension, extensionManager, extensionsState.get(extension.name)!.status, dispatch, enableExtensionReloading, ), ), ) ).filter((updateInfo) => !!updateInfo); } export interface ExtensionUpdateCheckResult { state: ExtensionUpdateState; error?: string; } export async function checkForAllExtensionUpdates( extensions: GeminiCLIExtension[], extensionManager: ExtensionManager, dispatch: (action: ExtensionUpdateAction) => void, ): Promise { dispatch({ type: 'BATCH_CHECK_START' }); try { const promises: Array> = []; for (const extension of extensions) { if (!extension.installMetadata) { dispatch({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.NOT_UPDATABLE, }, }); continue; } dispatch({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.CHECKING_FOR_UPDATES, }, }); promises.push( checkForExtensionUpdate(extension, extensionManager).then((state) => dispatch({ type: 'SET_STATE', payload: { name: extension.name, state }, }), ), ); } await Promise.all(promises); } finally { dispatch({ type: 'BATCH_CHECK_END' }); } } ================================================ FILE: packages/cli/src/config/extensions/variableSchema.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export interface VariableDefinition { type: 'string'; description: string; default?: string; required?: boolean; } export interface VariableSchema { [key: string]: VariableDefinition; } const PATH_SEPARATOR_DEFINITION = { type: 'string', description: 'The path separator.', } as const; export const VARIABLE_SCHEMA = { extensionPath: { type: 'string', description: 'The path of the extension in the filesystem.', }, workspacePath: { type: 'string', description: 'The absolute path of the current workspace.', }, '/': PATH_SEPARATOR_DEFINITION, pathSeparator: PATH_SEPARATOR_DEFINITION, } as const; ================================================ FILE: packages/cli/src/config/extensions/variables.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { expect, describe, it } from 'vitest'; import { hydrateString, recursivelyHydrateStrings, validateVariables, type VariableContext, } from './variables.js'; describe('validateVariables', () => { it('should not throw if all required variables are present', () => { const schema = { extensionPath: { type: 'string', description: 'test', required: true }, } as const; const context = { extensionPath: 'value' }; expect(() => validateVariables(context, schema)).not.toThrow(); }); it('should throw if a required variable is missing', () => { const schema = { extensionPath: { type: 'string', description: 'test', required: true }, } as const; const context = {}; expect(() => validateVariables(context, schema)).toThrow( 'Missing required variable: extensionPath', ); }); }); describe('hydrateString', () => { it('should replace a single variable', () => { const context = { extensionPath: 'path/my-extension', }; const result = hydrateString('Hello, ${extensionPath}!', context); expect(result).toBe('Hello, path/my-extension!'); }); it('should replace multiple variables', () => { const context = { extensionPath: 'path/my-extension', workspacePath: '/ws', }; const result = hydrateString( 'Ext: ${extensionPath}, WS: ${workspacePath}', context, ); expect(result).toBe('Ext: path/my-extension, WS: /ws'); }); it('should ignore unknown variables', () => { const context = { extensionPath: 'path/my-extension', }; const result = hydrateString('Hello, ${unknown}!', context); expect(result).toBe('Hello, ${unknown}!'); }); it('should handle null and undefined context values', () => { const context: VariableContext = { extensionPath: undefined, }; const result = hydrateString( 'Ext: ${extensionPath}, WS: ${workspacePath}', context, ); expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}'); }); }); describe('recursivelyHydrateStrings', () => { const context = { extensionPath: 'path/my-extension', workspacePath: '/ws', }; it('should hydrate strings in a flat object', () => { const obj = { a: 'Hello, ${workspacePath}', b: 'Hi, ${extensionPath}', }; const result = recursivelyHydrateStrings(obj, context); expect(result).toEqual({ a: 'Hello, /ws', b: 'Hi, path/my-extension', }); }); it('should hydrate strings in an array', () => { const arr = ['${workspacePath}', '${extensionPath}']; const result = recursivelyHydrateStrings(arr, context); expect(result).toEqual(['/ws', 'path/my-extension']); }); it('should hydrate strings in a nested object', () => { const obj = { a: 'Hello, ${workspacePath}', b: { c: 'Hi, ${extensionPath}', d: ['${workspacePath}/foo'], }, }; const result = recursivelyHydrateStrings(obj, context); expect(result).toEqual({ a: 'Hello, /ws', b: { c: 'Hi, path/my-extension', d: ['/ws/foo'], }, }); }); it('should not modify non-string values', () => { const obj = { a: 123, b: true, c: null, }; const result = recursivelyHydrateStrings(obj, context); expect(result).toEqual(obj); }); it('should not allow prototype pollution via __proto__', () => { const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}'); const result = recursivelyHydrateStrings(payload, context); expect(result.polluted).toBeUndefined(); expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe( false, ); }); it('should not allow prototype pollution via constructor', () => { const payload = JSON.parse( '{"constructor": {"prototype": {"polluted": "yes"}}}', ); const result = recursivelyHydrateStrings(payload, context); expect(result.polluted).toBeUndefined(); }); it('should not allow prototype pollution via prototype', () => { const payload = JSON.parse('{"prototype": {"polluted": "yes"}}'); const result = recursivelyHydrateStrings(payload, context); expect(result.polluted).toBeUndefined(); }); }); ================================================ FILE: packages/cli/src/config/extensions/variables.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import { GEMINI_DIR } from '@google/gemini-cli-core'; /** * Represents a set of keys that will be considered invalid while unmarshalling * JSON in recursivelyHydrateStrings. */ const UNMARSHALL_KEY_IGNORE_LIST: Set = new Set([ '__proto__', 'constructor', 'prototype', ]); export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; export const EXTENSION_SETTINGS_FILENAME = '.env'; export type JsonObject = { [key: string]: JsonValue }; export type JsonArray = JsonValue[]; export type JsonValue = | string | number | boolean | null | JsonObject | JsonArray; export type VariableContext = { [key: string]: string | undefined; }; export function validateVariables( variables: VariableContext, schema: VariableSchema, ) { for (const key in schema) { const definition = schema[key]; if (definition.required && !variables[key]) { throw new Error(`Missing required variable: ${key}`); } } } export function hydrateString(str: string, context: VariableContext): string { validateVariables(context, VARIABLE_SCHEMA); const regex = /\${(.*?)}/g; return str.replace(regex, (match, key) => context[key] == null ? match : context[key], ); } export function recursivelyHydrateStrings( obj: T, values: VariableContext, ): T { if (typeof obj === 'string') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return hydrateString(obj, values) as unknown as T; } if (Array.isArray(obj)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return obj.map((item) => // eslint-disable-next-line @typescript-eslint/no-unsafe-return recursivelyHydrateStrings(item, values), ) as unknown as T; } if (typeof obj === 'object' && obj !== null) { const newObj: Record = {}; for (const key in obj) { if ( !UNMARSHALL_KEY_IGNORE_LIST.has(key) && Object.prototype.hasOwnProperty.call(obj, key) ) { newObj[key] = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (obj as Record)[key], values, ); } } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return newObj as T; } return obj; } ================================================ FILE: packages/cli/src/config/footerItems.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { deriveItemsFromLegacySettings } from './footerItems.js'; import { createMockSettings } from '../test-utils/settings.js'; describe('deriveItemsFromLegacySettings', () => { it('returns defaults when no legacy settings are customized', () => { const settings = createMockSettings({ ui: { footer: { hideContextPercentage: true } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).toEqual([ 'workspace', 'git-branch', 'sandbox', 'model-name', 'quota', ]); }); it('removes workspace when hideCWD is true', () => { const settings = createMockSettings({ ui: { footer: { hideCWD: true, hideContextPercentage: true } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).not.toContain('workspace'); }); it('removes sandbox when hideSandboxStatus is true', () => { const settings = createMockSettings({ ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).not.toContain('sandbox'); }); it('removes model-name, context-used, and quota when hideModelInfo is true', () => { const settings = createMockSettings({ ui: { footer: { hideModelInfo: true, hideContextPercentage: true } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).not.toContain('model-name'); expect(items).not.toContain('context-used'); expect(items).not.toContain('quota'); }); it('includes context-used when hideContextPercentage is false', () => { const settings = createMockSettings({ ui: { footer: { hideContextPercentage: false } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).toContain('context-used'); // Should be after model-name const modelIdx = items.indexOf('model-name'); const contextIdx = items.indexOf('context-used'); expect(contextIdx).toBe(modelIdx + 1); }); it('includes memory-usage when showMemoryUsage is true', () => { const settings = createMockSettings({ ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).toContain('memory-usage'); }); it('handles combination of settings', () => { const settings = createMockSettings({ ui: { showMemoryUsage: true, footer: { hideCWD: true, hideModelInfo: true, hideContextPercentage: false, }, }, }).merged; const items = deriveItemsFromLegacySettings(settings); expect(items).toEqual([ 'git-branch', 'sandbox', 'context-used', 'memory-usage', ]); }); }); ================================================ FILE: packages/cli/src/config/footerItems.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { MergedSettings } from './settings.js'; export const ALL_ITEMS = [ { id: 'workspace', header: 'workspace (/directory)', description: 'Current working directory', }, { id: 'git-branch', header: 'branch', description: 'Current git branch name (not shown when unavailable)', }, { id: 'sandbox', header: 'sandbox', description: 'Sandbox type and trust indicator', }, { id: 'model-name', header: '/model', description: 'Current model identifier', }, { id: 'context-used', header: 'context', description: 'Percentage of context window used', }, { id: 'quota', header: '/stats', description: 'Remaining usage on daily limit (not shown when unavailable)', }, { id: 'memory-usage', header: 'memory', description: 'Memory used by the application', }, { id: 'session-id', header: 'session', description: 'Unique identifier for the current session', }, { id: 'code-changes', header: 'diff', description: 'Lines added/removed in the session (not shown when zero)', }, { id: 'token-count', header: 'tokens', description: 'Total tokens used in the session (not shown when zero)', }, ] as const; export type FooterItemId = (typeof ALL_ITEMS)[number]['id']; export const DEFAULT_ORDER = [ 'workspace', 'git-branch', 'sandbox', 'model-name', 'context-used', 'quota', 'memory-usage', 'session-id', 'code-changes', 'token-count', ]; export function deriveItemsFromLegacySettings( settings: MergedSettings, ): string[] { const defaults = [ 'workspace', 'git-branch', 'sandbox', 'model-name', 'quota', ]; const items = [...defaults]; const remove = (arr: string[], id: string) => { const idx = arr.indexOf(id); if (idx !== -1) arr.splice(idx, 1); }; if (settings.ui.footer.hideCWD) remove(items, 'workspace'); if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox'); if (settings.ui.footer.hideModelInfo) { remove(items, 'model-name'); remove(items, 'context-used'); remove(items, 'quota'); } if ( !settings.ui.footer.hideContextPercentage && !items.includes('context-used') ) { const modelIdx = items.indexOf('model-name'); if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used'); else items.push('context-used'); } if (settings.ui.showMemoryUsage) items.push('memory-usage'); return items; } const VALID_IDS: Set = new Set(ALL_ITEMS.map((i) => i.id)); /** * Resolves the ordered list and selected set of footer items from settings. * Used by FooterConfigDialog to initialize and reset state. */ export function resolveFooterState(settings: MergedSettings): { orderedIds: string[]; selectedIds: Set; } { const source = ( settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings) ).filter((id: string) => VALID_IDS.has(id)); const others = DEFAULT_ORDER.filter((id) => !source.includes(id)); return { orderedIds: [...source, ...others], selectedIds: new Set(source), }; } ================================================ FILE: packages/cli/src/config/mcp/index.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export { McpServerEnablementManager, canLoadServer, normalizeServerId, isInSettingsList, type McpServerEnablementState, type McpServerEnablementConfig, type McpServerDisplayState, type EnablementCallbacks, type ServerLoadResult, } from './mcpServerEnablement.js'; ================================================ FILE: packages/cli/src/config/mcp/mcpServerEnablement.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, Storage: { ...actual.Storage, getGlobalGeminiDir: () => '/virtual-home/.gemini', }, }; }); import { McpServerEnablementManager, canLoadServer, normalizeServerId, isInSettingsList, type EnablementCallbacks, } from './mcpServerEnablement.js'; let inMemoryFs: Record = {}; function createMockEnablement( sessionDisabled: boolean, fileEnabled: boolean, ): EnablementCallbacks { return { isSessionDisabled: () => sessionDisabled, isFileEnabled: () => Promise.resolve(fileEnabled), }; } function setupFsMocks(): void { vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { const content = inMemoryFs[filePath.toString()]; if (content === undefined) { const error = new Error(`ENOENT: ${filePath}`); (error as NodeJS.ErrnoException).code = 'ENOENT'; throw error; } return content; }); vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => { inMemoryFs[filePath.toString()] = data.toString(); }); vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined); } describe('McpServerEnablementManager', () => { let manager: McpServerEnablementManager; beforeEach(() => { inMemoryFs = {}; setupFsMocks(); McpServerEnablementManager.resetInstance(); manager = McpServerEnablementManager.getInstance(); }); afterEach(() => { vi.restoreAllMocks(); McpServerEnablementManager.resetInstance(); }); it('should enable/disable servers with persistence', async () => { expect(await manager.isFileEnabled('server')).toBe(true); await manager.disable('server'); expect(await manager.isFileEnabled('server')).toBe(false); await manager.enable('server'); expect(await manager.isFileEnabled('server')).toBe(true); }); it('should handle session disable separately', async () => { manager.disableForSession('server'); expect(manager.isSessionDisabled('server')).toBe(true); expect(await manager.isFileEnabled('server')).toBe(true); expect(await manager.isEffectivelyEnabled('server')).toBe(false); manager.clearSessionDisable('server'); expect(await manager.isEffectivelyEnabled('server')).toBe(true); }); it('should be case-insensitive', async () => { await manager.disable('PlayWright'); expect(await manager.isFileEnabled('playwright')).toBe(false); }); it('should return correct display state', async () => { await manager.disable('file-disabled'); manager.disableForSession('session-disabled'); expect(await manager.getDisplayState('enabled')).toEqual({ enabled: true, isSessionDisabled: false, isPersistentDisabled: false, }); expect( (await manager.getDisplayState('file-disabled')).isPersistentDisabled, ).toBe(true); expect( (await manager.getDisplayState('session-disabled')).isSessionDisabled, ).toBe(true); }); it('should share session state across getInstance calls', () => { const instance1 = McpServerEnablementManager.getInstance(); const instance2 = McpServerEnablementManager.getInstance(); instance1.disableForSession('test-server'); expect(instance2.isSessionDisabled('test-server')).toBe(true); expect(instance1).toBe(instance2); }); }); describe('canLoadServer', () => { it('blocks when admin has disabled MCP', async () => { const result = await canLoadServer('s', { adminMcpEnabled: false }); expect(result.blockType).toBe('admin'); }); it('blocks when server is not in allowlist', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true, allowedList: ['other'], }); expect(result.blockType).toBe('allowlist'); }); it('blocks when server is in excludelist', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true, excludedList: ['s'], }); expect(result.blockType).toBe('excludelist'); }); it('blocks when server is session-disabled', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true, enablement: createMockEnablement(true, true), }); expect(result.blockType).toBe('session'); }); it('blocks when server is file-disabled', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true, enablement: createMockEnablement(false, false), }); expect(result.blockType).toBe('enablement'); }); it('allows when admin MCP is enabled and no restrictions', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true }); expect(result.allowed).toBe(true); }); it('allows when server passes all checks', async () => { const result = await canLoadServer('s', { adminMcpEnabled: true, allowedList: ['s'], enablement: createMockEnablement(false, true), }); expect(result.allowed).toBe(true); }); }); describe('helper functions', () => { it('normalizeServerId lowercases and trims', () => { expect(normalizeServerId(' PlayWright ')).toBe('playwright'); }); it('isInSettingsList supports ext: backward compat', () => { expect(isInSettingsList('playwright', ['playwright']).found).toBe(true); expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true); expect( isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning, ).toBeTruthy(); }); }); ================================================ FILE: packages/cli/src/config/mcp/mcpServerEnablement.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs/promises'; import path from 'node:path'; import { Storage, coreEvents } from '@google/gemini-cli-core'; /** * Stored in JSON file - represents persistent enablement state. */ export interface McpServerEnablementState { enabled: boolean; } /** * File config format - map of server ID to enablement state. */ export interface McpServerEnablementConfig { [serverId: string]: McpServerEnablementState; } /** * For UI display - combines file and session state. */ export interface McpServerDisplayState { /** Effective state (considering session override) */ enabled: boolean; /** True if disabled via --session flag */ isSessionDisabled: boolean; /** True if disabled in file */ isPersistentDisabled: boolean; } /** * Callback types for enablement checks (passed from CLI to core). */ export interface EnablementCallbacks { isSessionDisabled: (serverId: string) => boolean; isFileEnabled: (serverId: string) => Promise; } /** * Result of canLoadServer check. */ export interface ServerLoadResult { allowed: boolean; reason?: string; blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement'; } /** * Normalize a server ID to canonical lowercase form. */ export function normalizeServerId(serverId: string): string { return serverId.toLowerCase().trim(); } /** * Check if a server ID is in a settings list (with backward compatibility). * Handles case-insensitive matching and plain name fallback for ext: servers. */ export function isInSettingsList( serverId: string, list: string[], ): { found: boolean; deprecationWarning?: string } { const normalizedId = normalizeServerId(serverId); const normalizedList = list.map(normalizeServerId); // Exact canonical match if (normalizedList.includes(normalizedId)) { return { found: true }; } // Backward compat: for ext: servers, check if plain name matches if (normalizedId.startsWith('ext:')) { const plainName = normalizedId.split(':').pop(); if (plainName && normalizedList.includes(plainName)) { return { found: true, deprecationWarning: `Settings reference '${plainName}' matches extension server '${serverId}'. ` + `Update your settings to use the full identifier '${serverId}' instead.`, }; } } return { found: false }; } /** * Single source of truth for whether a server can be loaded. * Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers. * * Uses callbacks instead of direct enablementManager reference to keep * packages/core independent of packages/cli. */ export async function canLoadServer( serverId: string, config: { adminMcpEnabled: boolean; allowedList?: string[]; excludedList?: string[]; enablement?: EnablementCallbacks; }, ): Promise { const normalizedId = normalizeServerId(serverId); // 1. Admin kill switch if (!config.adminMcpEnabled) { return { allowed: false, reason: 'MCP servers are disabled by administrator. Check admin settings or contact your admin.', blockType: 'admin', }; } // 2. Allowlist check if (config.allowedList && config.allowedList.length > 0) { const { found, deprecationWarning } = isInSettingsList( normalizedId, config.allowedList, ); if (deprecationWarning) { coreEvents.emitFeedback('warning', deprecationWarning); } if (!found) { return { allowed: false, reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`, blockType: 'allowlist', }; } } // 3. Excludelist check if (config.excludedList) { const { found, deprecationWarning } = isInSettingsList( normalizedId, config.excludedList, ); if (deprecationWarning) { coreEvents.emitFeedback('warning', deprecationWarning); } if (found) { return { allowed: false, reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`, blockType: 'excludelist', }; } } // 4. Session disable check (before file-based enablement) if (config.enablement?.isSessionDisabled(normalizedId)) { return { allowed: false, reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`, blockType: 'session', }; } // 5. File-based enablement check if ( config.enablement && !(await config.enablement.isFileEnabled(normalizedId)) ) { return { allowed: false, reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`, blockType: 'enablement', }; } return { allowed: true }; } const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json'; /** * McpServerEnablementManager * * Manages the enabled/disabled state of MCP servers. * Uses a simplified format compared to ExtensionEnablementManager. * Supports both persistent (file) and session-only (in-memory) states. * * NOTE: Use getInstance() to get the singleton instance. This ensures * session state (sessionDisabled Set) is shared across all code paths. */ export class McpServerEnablementManager { private static instance: McpServerEnablementManager | null = null; private readonly configFilePath: string; private readonly configDir: string; private readonly sessionDisabled = new Set(); /** * Get the singleton instance. */ static getInstance(): McpServerEnablementManager { if (!McpServerEnablementManager.instance) { McpServerEnablementManager.instance = new McpServerEnablementManager(); } return McpServerEnablementManager.instance; } /** * Reset the singleton instance (for testing only). */ static resetInstance(): void { McpServerEnablementManager.instance = null; } constructor() { this.configDir = Storage.getGlobalGeminiDir(); this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME); } /** * Check if server is enabled in FILE (persistent config only). * Does NOT include session state. */ async isFileEnabled(serverName: string): Promise { const config = await this.readConfig(); const state = config[normalizeServerId(serverName)]; return state?.enabled ?? true; } /** * Check if server is session-disabled. */ isSessionDisabled(serverName: string): boolean { return this.sessionDisabled.has(normalizeServerId(serverName)); } /** * Check effective enabled state (combines file + session). * Convenience method; canLoadServer() uses separate callbacks for granular blockType. */ async isEffectivelyEnabled(serverName: string): Promise { if (this.isSessionDisabled(serverName)) { return false; } return this.isFileEnabled(serverName); } /** * Enable a server persistently. * Removes the server from config file (defaults to enabled). */ async enable(serverName: string): Promise { const normalizedName = normalizeServerId(serverName); const config = await this.readConfig(); if (normalizedName in config) { delete config[normalizedName]; await this.writeConfig(config); } } /** * Disable a server persistently. * Adds server to config file with enabled: false. */ async disable(serverName: string): Promise { const config = await this.readConfig(); config[normalizeServerId(serverName)] = { enabled: false }; await this.writeConfig(config); } /** * Disable a server for current session only (in-memory). */ disableForSession(serverName: string): void { this.sessionDisabled.add(normalizeServerId(serverName)); } /** * Clear session disable for a server. */ clearSessionDisable(serverName: string): void { this.sessionDisabled.delete(normalizeServerId(serverName)); } /** * Get display state for a specific server (for UI). */ async getDisplayState(serverName: string): Promise { const isSessionDisabled = this.isSessionDisabled(serverName); const isPersistentDisabled = !(await this.isFileEnabled(serverName)); return { enabled: !isSessionDisabled && !isPersistentDisabled, isSessionDisabled, isPersistentDisabled, }; } /** * Get all display states (for UI listing). */ async getAllDisplayStates( serverIds: string[], ): Promise> { const result: Record = {}; for (const serverId of serverIds) { result[normalizeServerId(serverId)] = await this.getDisplayState(serverId); } return result; } /** * Get enablement callbacks for passing to core. */ getEnablementCallbacks(): EnablementCallbacks { return { isSessionDisabled: (id) => this.isSessionDisabled(id), isFileEnabled: (id) => this.isFileEnabled(id), }; } /** * Auto-enable any disabled MCP servers by name. * Returns server names that were actually re-enabled. */ async autoEnableServers(serverNames: string[]): Promise { const enabledServers: string[] = []; for (const serverName of serverNames) { const normalizedName = normalizeServerId(serverName); const state = await this.getDisplayState(normalizedName); let wasDisabled = false; if (state.isPersistentDisabled) { await this.enable(normalizedName); wasDisabled = true; } if (state.isSessionDisabled) { this.clearSessionDisable(normalizedName); wasDisabled = true; } if (wasDisabled) { enabledServers.push(serverName); } } return enabledServers; } /** * Read config from file asynchronously. */ private async readConfig(): Promise { try { const content = await fs.readFile(this.configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as McpServerEnablementConfig; } catch (error) { if ( error instanceof Error && 'code' in error && error.code === 'ENOENT' ) { return {}; } coreEvents.emitFeedback( 'error', 'Failed to read MCP server enablement config.', error, ); return {}; } } /** * Write config to file asynchronously. */ private async writeConfig(config: McpServerEnablementConfig): Promise { await fs.mkdir(this.configDir, { recursive: true }); await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2)); } } ================================================ FILE: packages/cli/src/config/policy-engine.integration.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApprovalMode, PolicyDecision, PolicyEngine, } from '@google/gemini-cli-core'; import { createPolicyEngineConfig } from './policy.js'; import type { Settings } from './settings.js'; // Mock Storage to ensure tests are hermetic and don't read from user's home directory vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); const Storage = actual.Storage; // Monkey-patch static methods Storage.getUserPoliciesDir = () => '/non-existent/user/policies'; Storage.getSystemPoliciesDir = () => '/non-existent/system/policies'; return { ...actual, Storage, }; }); describe('Policy Engine Integration Tests', () => { beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', '')); afterEach(() => vi.unstubAllEnvs()); describe('Policy configuration produces valid PolicyEngine config', () => { it('should create a working PolicyEngine from basic settings', async () => { const settings: Settings = { tools: { allowed: ['run_shell_command'], exclude: ['write_file'], }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Allowed tool should be allowed expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); // Excluded tool should be denied expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.DENY); // Other write tools should ask user expect( (await engine.check({ name: 'replace' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); // Unknown tools should use default expect( (await engine.check({ name: 'unknown_tool' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); }); it('should handle MCP server wildcard patterns correctly', async () => { const settings: Settings = { mcp: { allowed: ['allowed-server'], excluded: ['blocked-server'], }, mcpServers: { 'trusted-server': { command: 'node', args: ['server.js'], trust: true, }, }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Tools from allowed server should be allowed // Tools from allowed server should be allowed expect( (await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( { name: 'mcp_allowed-server_another_tool' }, undefined, ) ).decision, ).toBe(PolicyDecision.ALLOW); // Tools from trusted server should be allowed expect( (await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( { name: 'mcp_trusted-server_special_tool' }, undefined, ) ).decision, ).toBe(PolicyDecision.ALLOW); // Tools from blocked server should be denied expect( (await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); // Tools from unknown servers should use default expect( (await engine.check({ name: 'mcp_unknown-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ASK_USER); }); it('should handle global MCP wildcard (*) in settings', async () => { const settings: Settings = { mcp: { allowed: ['*'], }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // ANY tool with a server name should be allowed expect( (await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server')) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( { name: 'mcp_another-server_tool' }, 'another-server', ) ).decision, ).toBe(PolicyDecision.ALLOW); // Built-in tools should NOT be allowed by the MCP wildcard expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); }); it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => { const settings: Settings = { mcp: { allowed: ['my-server'], }, tools: { exclude: ['mcp_my-server_dangerous-tool'], }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // MCP server allowed (priority 4.1) provides general allow for server // MCP server allowed (priority 4.1) provides general allow for server expect( (await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); // But specific tool exclude (priority 4.4) wins over server allow expect( ( await engine.check( { name: 'mcp_my-server_dangerous-tool' }, undefined, ) ).decision, ).toBe(PolicyDecision.DENY); }); it('should handle complex mixed configurations', async () => { const settings: Settings = { tools: { allowed: ['custom-tool', 'mcp_my-server_special-tool'], exclude: ['glob', 'dangerous-tool'], }, mcp: { allowed: ['allowed-server'], excluded: ['blocked-server'], }, mcpServers: { 'trusted-server': { command: 'node', args: ['server.js'], trust: true, }, }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Read-only tools should be allowed (autoAccept) expect( (await engine.check({ name: 'read_file' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'list_directory' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); // But glob is explicitly excluded, so it should be denied expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( PolicyDecision.DENY, ); // Replace should ask user (normal write tool behavior) expect( (await engine.check({ name: 'replace' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); // Explicitly allowed tools expect( (await engine.check({ name: 'custom-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); // MCP server tools expect( (await engine.check({ name: 'mcp_allowed-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'mcp_trusted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'mcp_blocked-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); // Write tools should ask by default expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); }); it('should handle YOLO mode correctly', async () => { const settings: Settings = { tools: { exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.YOLO, ); const engine = new PolicyEngine(config); // Most tools should be allowed in YOLO mode expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'unknown_tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); // But explicitly excluded tools should still be denied expect( (await engine.check({ name: 'dangerous-tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); }); it('should handle AUTO_EDIT mode correctly', async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.AUTO_EDIT, ); const engine = new PolicyEngine(config); // Edit tools should be allowed in AUTO_EDIT mode expect( (await engine.check({ name: 'replace' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); // Other tools should follow normal rules expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); }); it('should handle Plan mode correctly', async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.PLAN, ); const engine = new PolicyEngine(config); // Read and search tools should be allowed expect( (await engine.check({ name: 'read_file' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'google_web_search' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'list_directory' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'get_internal_docs' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'cli_help' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); // Other tools should be denied via catch all expect( (await engine.check({ name: 'replace' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.DENY); // Unknown tools should be denied via catch-all expect( (await engine.check({ name: 'unknown_tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); }); it('should correctly match tool annotations', async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); // Add a manual rule with annotations to the config config.rules = config.rules || []; config.rules.push({ toolAnnotations: { readOnlyHint: true }, decision: PolicyDecision.ALLOW, priority: 10, }); const engine = new PolicyEngine(config); // A tool with readOnlyHint=true should be ALLOWED const roCall = { name: 'some_tool', args: {} }; const roMeta = { readOnlyHint: true }; expect((await engine.check(roCall, undefined, roMeta)).decision).toBe( PolicyDecision.ALLOW, ); // A tool without the hint (or with false) should follow default decision (ASK_USER) const rwMeta = { readOnlyHint: false }; expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe( PolicyDecision.ASK_USER, ); }); describe.each(['write_file', 'replace'])( 'Plan Mode policy for %s', (toolName) => { it(`should allow ${toolName} to plans directory`, async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.PLAN, ); const engine = new PolicyEngine(config); // Valid plan file paths const validPaths = [ '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md', '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md', '/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory 'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md', 'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID ]; for (const file_path of validPaths) { expect( ( await engine.check( { name: toolName, args: { file_path } }, undefined, ) ).decision, ).toBe(PolicyDecision.ALLOW); } }); it(`should deny ${toolName} outside plans directory`, async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.PLAN, ); const engine = new PolicyEngine(config); const invalidPaths = [ '/project/src/file.ts', // Workspace '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix) 'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows) '/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir ]; for (const file_path of invalidPaths) { expect( ( await engine.check( { name: toolName, args: { file_path } }, undefined, ) ).decision, ).toBe(PolicyDecision.DENY); } }); }, ); it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { tools: { allowed: ['specific-tool'], // Priority 100 exclude: ['blocked-tool'], // Priority 200 }, mcp: { allowed: ['mcp-server'], // Priority 85 excluded: ['blocked-server'], // Priority 195 }, mcpServers: { 'trusted-server': { command: 'node', args: ['server.js'], trust: true, // Priority 90 }, }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Test that priorities are applied correctly const rules = config.rules || []; // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'mcp_blocked-server_*', ); expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); expect(specificToolRule?.priority).toBe(4.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'mcp_trusted-server_*', ); expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server const mcpServerRule = rules.find( (r) => r.toolName === 'mcp_mcp-server_*', ); expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server const readOnlyToolRule = rules.find( (r) => r.toolName === 'glob' && !r.subagent, ); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); // Verify the engine applies these priorities correctly expect( (await engine.check({ name: 'blocked-tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'mcp_blocked-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'specific-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'mcp_trusted-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( (await engine.check({ name: 'mcp_mcp-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( PolicyDecision.ALLOW, ); }); it('should handle edge case: MCP server with both trust and exclusion', async () => { const settings: Settings = { mcpServers: { 'conflicted-server': { command: 'node', args: ['server.js'], trust: true, // Priority 90 - ALLOW }, }, mcp: { excluded: ['conflicted-server'], // Priority 195 - DENY }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Exclusion (195) should win over trust (90) expect( (await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); it('should handle edge case: specific tool allowed but server excluded', async () => { const settings: Settings = { mcp: { excluded: ['my-server'], // Priority 195 - DENY }, tools: { allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Server exclusion (195) wins over specific tool allow (100) // This might be counterintuitive but follows the priority system expect( (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'mcp_my-server_other-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); it('should verify non-interactive mode transformation', async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); // Enable non-interactive mode const engineConfig = { ...config, nonInteractive: true }; const engine = new PolicyEngine(engineConfig); // ASK_USER should become DENY in non-interactive mode expect( (await engine.check({ name: 'unknown_tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'run_shell_command' }, undefined)).decision, ).toBe(PolicyDecision.DENY); }); it('should handle empty settings gracefully', async () => { const settings: Settings = {}; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const engine = new PolicyEngine(config); // Should have default rules for write tools expect( (await engine.check({ name: 'write_file' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); expect( (await engine.check({ name: 'replace' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); // Unknown tools should use default expect( (await engine.check({ name: 'unknown' }, undefined)).decision, ).toBe(PolicyDecision.ASK_USER); }); it('should verify rules are created with correct priorities', async () => { const settings: Settings = { tools: { allowed: ['tool1', 'tool2'], exclude: ['tool3'], }, mcp: { allowed: ['server1'], excluded: ['server2'], }, }; const config = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); const rules = config.rules || []; // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*'); expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); // Priority 70 in default tier → 1.07 expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only // The PolicyEngine will sort these by priority when it's created const engine = new PolicyEngine(config); const sortedRules = engine.getRules(); // Verify the engine sorted them correctly for (let i = 1; i < sortedRules.length; i++) { const prevPriority = sortedRules[i - 1].priority ?? 0; const currPriority = sortedRules[i].priority ?? 0; expect(prevPriority).toBeGreaterThanOrEqual(currPriority); } }); }); }); ================================================ FILE: packages/cli/src/config/policy.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { resolveWorkspacePolicyState, autoAcceptWorkspacePolicies, setAutoAcceptWorkspacePolicies, disableWorkspacePolicies, setDisableWorkspacePolicies, } from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; // Mock debugLogger to avoid noise in test output vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, writeToStderr: vi.fn(), }; }); describe('resolveWorkspacePolicyState', () => { let tempDir: string; let workspaceDir: string; let policiesDir: string; beforeEach(() => { // Create a temporary directory for the test tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage vi.stubEnv('GEMINI_CLI_HOME', tempDir); workspaceDir = path.join(tempDir, 'workspace'); fs.mkdirSync(workspaceDir); policiesDir = path.join(workspaceDir, '.gemini', 'policies'); // Enable policies for these tests to verify loading logic setDisableWorkspacePolicies(false); vi.clearAllMocks(); }); afterEach(() => { // Clean up temporary directory fs.rmSync(tempDir, { recursive: true, force: true }); vi.unstubAllEnvs(); }); it('should return empty state if folder is not trusted', async () => { const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: false, interactive: true, }); expect(result).toEqual({ workspacePoliciesDir: undefined, policyUpdateConfirmationRequest: undefined, }); }); it('should have disableWorkspacePolicies set to true by default', () => { // We explicitly set it to false in beforeEach for other tests, // so here we test that setting it to true works. setDisableWorkspacePolicies(true); expect(disableWorkspacePolicies).toBe(true); }); it('should return policy directory if integrity matches', async () => { // Set up policies directory with a file fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); // First call to establish integrity (interactive auto-accept) const firstResult = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); expect(firstResult.workspacePoliciesDir).toBe(policiesDir); expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined(); expect(writeToStderr).not.toHaveBeenCalled(); // Second call should match const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); expect(result.workspacePoliciesDir).toBe(policiesDir); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); it('should return undefined if integrity is NEW but fileCount is 0', async () => { const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); expect(result.workspacePoliciesDir).toBeUndefined(); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => { const originalValue = autoAcceptWorkspacePolicies; setAutoAcceptWorkspacePolicies(false); try { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); expect(result.workspacePoliciesDir).toBeUndefined(); expect(result.policyUpdateConfirmationRequest).toEqual({ scope: 'workspace', identifier: workspaceDir, policyDir: policiesDir, newHash: expect.any(String), }); } finally { setAutoAcceptWorkspacePolicies(originalValue); } }); it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: false, }); expect(result.workspacePoliciesDir).toBe(policiesDir); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); expect(writeToStderr).toHaveBeenCalledWith( expect.stringContaining('Automatically accepting and loading'), ); }); it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => { const originalValue = autoAcceptWorkspacePolicies; setAutoAcceptWorkspacePolicies(false); try { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: false, }); expect(result.workspacePoliciesDir).toBe(policiesDir); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); expect(writeToStderr).toHaveBeenCalledWith( expect.stringContaining('Automatically accepting and loading'), ); } finally { setAutoAcceptWorkspacePolicies(originalValue); } }); it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); // Run from HOME directory (tempDir is mocked as HOME in beforeEach) const result = await resolveWorkspacePolicyState({ cwd: tempDir, trustedFolder: true, interactive: true, }); expect(result.workspacePoliciesDir).toBeUndefined(); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => { setDisableWorkspacePolicies(true); // Set up policies directory with a file fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); expect(result).toEqual({ workspacePoliciesDir: undefined, policyUpdateConfirmationRequest: undefined, }); }); it('should return empty state if cwd is a symlink to the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); // Create a symlink to the home directory const symlinkDir = path.join( os.tmpdir(), `gemini-cli-symlink-${Date.now()}`, ); fs.symlinkSync(tempDir, symlinkDir, 'dir'); try { // Run from symlink to HOME directory const result = await resolveWorkspacePolicyState({ cwd: symlinkDir, trustedFolder: true, interactive: true, }); expect(result.workspacePoliciesDir).toBeUndefined(); expect(result.policyUpdateConfirmationRequest).toBeUndefined(); } finally { // Clean up symlink fs.unlinkSync(symlinkDir); } }); }); ================================================ FILE: packages/cli/src/config/policy.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type PolicyEngineConfig, type ApprovalMode, type PolicyEngine, type MessageBus, type PolicySettings, createPolicyEngineConfig as createCorePolicyEngineConfig, createPolicyUpdater as createCorePolicyUpdater, PolicyIntegrityManager, IntegrityStatus, Storage, type PolicyUpdateConfirmationRequest, writeToStderr, debugLogger, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; /** * Temporary flag to automatically accept workspace policies to reduce friction. * Exported as 'let' to allow monkey patching in tests via the setter. */ export let autoAcceptWorkspacePolicies = true; /** * Sets the autoAcceptWorkspacePolicies flag. * Used primarily for testing purposes. */ export function setAutoAcceptWorkspacePolicies(value: boolean) { autoAcceptWorkspacePolicies = value; } /** * Temporary flag to disable workspace level policies altogether. * Exported as 'let' to allow monkey patching in tests via the setter. */ export let disableWorkspacePolicies = true; /** * Sets the disableWorkspacePolicies flag. * Used primarily for testing purposes. */ export function setDisableWorkspacePolicies(value: boolean) { disableWorkspacePolicies = value; } export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, workspacePoliciesDir?: string, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. const policySettings: PolicySettings = { mcp: settings.mcp, tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, adminPolicyPaths: settings.adminPolicyPaths, workspacePoliciesDir, disableAlwaysAllow: settings.security?.disableAlwaysAllow || settings.admin?.secureModeEnabled, }; return createCorePolicyEngineConfig(policySettings, approvalMode); } export function createPolicyUpdater( policyEngine: PolicyEngine, messageBus: MessageBus, storage: Storage, ) { return createCorePolicyUpdater(policyEngine, messageBus, storage); } export interface WorkspacePolicyState { workspacePoliciesDir?: string; policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; } /** * Resolves the workspace policy state by checking folder trust and policy integrity. */ export async function resolveWorkspacePolicyState(options: { cwd: string; trustedFolder: boolean; interactive: boolean; }): Promise { const { cwd, trustedFolder, interactive } = options; let workspacePoliciesDir: string | undefined; let policyUpdateConfirmationRequest: | PolicyUpdateConfirmationRequest | undefined; if (trustedFolder && !disableWorkspacePolicies) { const storage = new Storage(cwd); // If we are in the home directory (or rather, our target Gemini dir is the global one), // don't treat it as a workspace to avoid loading global policies twice. if (storage.isWorkspaceHomeDir()) { return { workspacePoliciesDir: undefined }; } const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir(); const integrityManager = new PolicyIntegrityManager(); const integrityResult = await integrityManager.checkIntegrity( 'workspace', cwd, potentialWorkspacePoliciesDir, ); if (integrityResult.status === IntegrityStatus.MATCH) { workspacePoliciesDir = potentialWorkspacePoliciesDir; } else if ( integrityResult.status === IntegrityStatus.NEW && integrityResult.fileCount === 0 ) { // No workspace policies found workspacePoliciesDir = undefined; } else if (interactive && !autoAcceptWorkspacePolicies) { // Policies changed or are new, and we are in interactive mode and auto-accept is disabled policyUpdateConfirmationRequest = { scope: 'workspace', identifier: cwd, policyDir: potentialWorkspacePoliciesDir, newHash: integrityResult.hash, }; } else { // Non-interactive mode or auto-accept is enabled: automatically accept/load await integrityManager.acceptIntegrity( 'workspace', cwd, integrityResult.hash, ); workspacePoliciesDir = potentialWorkspacePoliciesDir; if (!interactive) { writeToStderr( 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n', ); } else { debugLogger.warn( 'Workspace policies changed or are new. Automatically accepting and loading them.', ); } } } return { workspacePoliciesDir, policyUpdateConfirmationRequest }; } ================================================ FILE: packages/cli/src/config/sandboxConfig.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getPackageJson } from '@google/gemini-cli-core'; import commandExists from 'command-exists'; import * as os from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadSandboxConfig } from './sandboxConfig.js'; // Mock dependencies vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), getPackageJson: vi.fn(), FatalSandboxError: class extends Error { constructor(message: string) { super(message); this.name = 'FatalSandboxError'; } }, }; }); vi.mock('command-exists', () => { const sync = vi.fn(); return { sync, default: { sync, }, }; }); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), platform: vi.fn(), }; }); const mockedGetPackageJson = vi.mocked(getPackageJson); const mockedCommandExistsSync = vi.mocked(commandExists.sync); const mockedOsPlatform = vi.mocked(os.platform); describe('loadSandboxConfig', () => { const originalEnv = { ...process.env }; beforeEach(() => { vi.resetAllMocks(); process.env = { ...originalEnv }; delete process.env['SANDBOX']; delete process.env['GEMINI_SANDBOX']; mockedGetPackageJson.mockResolvedValue({ config: { sandboxImageUri: 'default/image' }, }); }); afterEach(() => { process.env = originalEnv; }); it('should return undefined if sandbox is explicitly disabled via argv', async () => { const config = await loadSandboxConfig({}, { sandbox: false }); expect(config).toBeUndefined(); }); it('should return undefined if sandbox is explicitly disabled via settings', async () => { const config = await loadSandboxConfig({ tools: { sandbox: false } }, {}); expect(config).toBeUndefined(); }); it('should return undefined if sandbox is not configured', async () => { const config = await loadSandboxConfig({}, {}); expect(config).toBeUndefined(); }); it('should return undefined if already inside a sandbox (SANDBOX env var is set)', async () => { process.env['SANDBOX'] = '1'; const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toBeUndefined(); }); describe('with GEMINI_SANDBOX environment variable', () => { it('should use docker if GEMINI_SANDBOX=docker and it exists', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); it('should throw if GEMINI_SANDBOX is an invalid command', async () => { process.env['GEMINI_SANDBOX'] = 'invalid-command'; await expect(loadSandboxConfig({}, {})).rejects.toThrow( "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); it('should throw if GEMINI_SANDBOX command does not exist', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(false); await expect(loadSandboxConfig({}, {})).rejects.toThrow( "Missing sandbox command 'docker' (from GEMINI_SANDBOX)", ); }); it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => { process.env['GEMINI_SANDBOX'] = 'lxc'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'lxc', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); }); it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => { process.env['GEMINI_SANDBOX'] = 'lxc'; mockedCommandExistsSync.mockReturnValue(false); await expect(loadSandboxConfig({}, {})).rejects.toThrow( "Missing sandbox command 'lxc' (from GEMINI_SANDBOX)", ); }); }); describe('with sandbox: true', () => { it('should use sandbox-exec on darwin if available', async () => { mockedOsPlatform.mockReturnValue('darwin'); mockedCommandExistsSync.mockImplementation( (cmd) => cmd === 'sandbox-exec', ); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); }); it('should prefer sandbox-exec over docker on darwin', async () => { mockedOsPlatform.mockReturnValue('darwin'); mockedCommandExistsSync.mockReturnValue(true); // all commands exist const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); }); it('should use docker if available and sandbox is true', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); const config = await loadSandboxConfig({ tools: { sandbox: true } }, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'default/image', }); }); it('should use podman if available and docker is not', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'podman', image: 'default/image', }); }); it('should throw if sandbox: true but no command is found', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockReturnValue(false); await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow( 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + 'install docker or podman or specify command in GEMINI_SANDBOX', ); }); }); describe("with sandbox: 'command'", () => { it('should use the specified command if it exists', async () => { mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, { sandbox: 'podman' }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'podman', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman'); }); it('should throw if the specified command does not exist', async () => { mockedCommandExistsSync.mockReturnValue(false); await expect( loadSandboxConfig({}, { sandbox: 'podman' }), ).rejects.toThrow( "Missing sandbox command 'podman' (from GEMINI_SANDBOX)", ); }); it('should throw if the specified command is invalid', async () => { await expect( loadSandboxConfig({}, { sandbox: 'invalid-command' }), ).rejects.toThrow( "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); }); describe('image configuration', () => { it('should use image from GEMINI_SANDBOX_IMAGE env var if set', async () => { process.env['GEMINI_SANDBOX_IMAGE'] = 'env/image'; process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'env/image', }); }); it('should use image from package.json if env var is not set', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'default/image', }); }); it('should return undefined if command is found but no image is configured', async () => { mockedGetPackageJson.mockResolvedValue({}); // no sandboxImageUri process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); expect(config).toBeUndefined(); }); }); describe('truthy/falsy sandbox values', () => { beforeEach(() => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); }); it.each([true, 'true', '1'])( 'should enable sandbox for value: %s', async (value) => { const config = await loadSandboxConfig({}, { sandbox: value }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'default/image', }); }, ); it.each([false, 'false', '0', undefined, null, ''])( 'should disable sandbox for value: %s', async (value) => { // `null` is not a valid type for the arg, but good to test falsiness const config = await loadSandboxConfig({}, { sandbox: value }); expect(config).toBeUndefined(); }, ); }); describe('with SandboxConfig object in settings', () => { beforeEach(() => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); }); it('should support object structure with enabled: true', async () => { const config = await loadSandboxConfig( { tools: { sandbox: { enabled: true, allowedPaths: ['/tmp'], networkAccess: true, }, }, }, {}, ); expect(config).toEqual({ enabled: true, allowedPaths: ['/tmp'], networkAccess: true, command: 'docker', image: 'default/image', }); }); it('should support object structure with explicit command', async () => { mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); const config = await loadSandboxConfig( { tools: { sandbox: { enabled: true, command: 'podman', allowedPaths: [], networkAccess: false, }, }, }, {}, ); expect(config?.command).toBe('podman'); }); it('should support object structure with custom image', async () => { const config = await loadSandboxConfig( { tools: { sandbox: { enabled: true, image: 'custom/image', allowedPaths: [], networkAccess: false, }, }, }, {}, ); expect(config?.image).toBe('custom/image'); }); it('should return undefined if enabled is false in object', async () => { const config = await loadSandboxConfig( { tools: { sandbox: { enabled: false, allowedPaths: [], networkAccess: false, }, }, }, {}, ); expect(config).toBeUndefined(); }); it('should prioritize CLI flag over settings object', async () => { const config = await loadSandboxConfig( { tools: { sandbox: { enabled: true, allowedPaths: ['/settings-path'], networkAccess: false, }, }, }, { sandbox: false }, ); expect(config).toBeUndefined(); }); }); describe('with sandbox: runsc (gVisor)', () => { beforeEach(() => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockReturnValue(true); }); it('should use runsc via CLI argument on Linux', async () => { const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'runsc', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); it('should use runsc via GEMINI_SANDBOX environment variable', async () => { process.env['GEMINI_SANDBOX'] = 'runsc'; const config = await loadSandboxConfig({}, {}); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'runsc', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); it('should use runsc via settings file', async () => { const config = await loadSandboxConfig( { tools: { sandbox: 'runsc' } }, {}, ); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'runsc', image: 'default/image', }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => { process.env['GEMINI_SANDBOX'] = 'runsc'; const config = await loadSandboxConfig( { tools: { sandbox: 'docker' } }, { sandbox: 'podman' }, ); expect(config).toEqual({ enabled: true, allowedPaths: [], networkAccess: false, command: 'runsc', image: 'default/image', }); }); it('should reject runsc on macOS (Linux-only)', async () => { mockedOsPlatform.mockReturnValue('darwin'); await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( 'gVisor (runsc) sandboxing is only supported on Linux', ); }); it('should reject runsc on Windows (Linux-only)', async () => { mockedOsPlatform.mockReturnValue('win32'); await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( 'gVisor (runsc) sandboxing is only supported on Linux', ); }); it('should throw if runsc binary not found', async () => { mockedCommandExistsSync.mockReturnValue(false); await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( "Missing sandbox command 'runsc' (from GEMINI_SANDBOX)", ); }); it('should throw if Docker not available (runsc requires Docker)', async () => { mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc'); await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", ); }); it('should NOT auto-detect runsc when both runsc and docker available', async () => { mockedCommandExistsSync.mockImplementation( (cmd) => cmd === 'runsc' || cmd === 'docker', ); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config?.command).toBe('docker'); expect(config?.command).not.toBe('runsc'); }); }); }); ================================================ FILE: packages/cli/src/config/sandboxConfig.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getPackageJson, type SandboxConfig, FatalSandboxError, } from '@google/gemini-cli-core'; import commandExists from 'command-exists'; import * as os from 'node:os'; import type { Settings } from './settings.js'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // This is a stripped-down version of the CliArgs interface from config.ts // to avoid circular dependencies. interface SandboxCliArgs { sandbox?: boolean | string | null; } const VALID_SANDBOX_COMMANDS = [ 'docker', 'podman', 'sandbox-exec', 'runsc', 'lxc', 'windows-native', ]; function isSandboxCommand( value: string, ): value is Exclude { return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( value, ); } function getSandboxCommand( sandbox?: boolean | string | null, ): SandboxConfig['command'] | '' { // If the SANDBOX env var is set, we're already inside the sandbox. if (process.env['SANDBOX']) { return ''; } // note environment variable takes precedence over argument (from command line or settings) const environmentConfiguredSandbox = process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? ''; sandbox = environmentConfiguredSandbox?.length > 0 ? environmentConfiguredSandbox : sandbox; if (sandbox === '1' || sandbox === 'true') sandbox = true; else if (sandbox === '0' || sandbox === 'false' || !sandbox) sandbox = false; if (sandbox === false) { return ''; } if (typeof sandbox === 'string' && sandbox) { if (!isSandboxCommand(sandbox)) { throw new FatalSandboxError( `Invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join( ', ', )}`, ); } // runsc (gVisor) is only supported on Linux if (sandbox === 'runsc' && os.platform() !== 'linux') { throw new FatalSandboxError( 'gVisor (runsc) sandboxing is only supported on Linux', ); } // windows-native is only supported on Windows if (sandbox === 'windows-native' && os.platform() !== 'win32') { throw new FatalSandboxError( 'Windows native sandboxing is only supported on Windows', ); } // confirm that specified command exists (unless it's built-in) if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); } // runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen) if (sandbox === 'runsc' && !commandExists.sync('docker')) { throw new FatalSandboxError( "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", ); } return sandbox; } // look for seatbelt, docker, or podman, in that order // for container-based sandboxing, require sandbox to be enabled explicitly // note: runsc is NOT auto-detected, it must be explicitly specified if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { return 'sandbox-exec'; } else if (commandExists.sync('docker') && sandbox === true) { return 'docker'; } else if (commandExists.sync('podman') && sandbox === true) { return 'podman'; } // throw an error if user requested sandbox but no command was found if (sandbox === true) { throw new FatalSandboxError( 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + 'install docker or podman or specify command in GEMINI_SANDBOX', ); } return ''; // Note: 'lxc' is intentionally not auto-detected because it requires a // pre-existing, running container managed by the user. Use // GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it. } export async function loadSandboxConfig( settings: Settings, argv: SandboxCliArgs, ): Promise { const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; let sandboxValue: boolean | string | null | undefined; let allowedPaths: string[] = []; let networkAccess = false; let customImage: string | undefined; if ( typeof sandboxOption === 'object' && sandboxOption !== null && !Array.isArray(sandboxOption) ) { const config = sandboxOption; sandboxValue = config.enabled ? (config.command ?? true) : false; allowedPaths = config.allowedPaths ?? []; networkAccess = config.networkAccess ?? false; customImage = config.image; } else if (typeof sandboxOption !== 'object' || sandboxOption === null) { sandboxValue = sandboxOption; } const command = getSandboxCommand(sandboxValue); const packageJson = await getPackageJson(__dirname); const image = process.env['GEMINI_SANDBOX_IMAGE'] ?? process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? customImage ?? packageJson?.config?.sandboxImageUri; const isNative = command === 'windows-native' || command === 'sandbox-exec' || command === 'lxc'; return command && (image || isNative) ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } ================================================ FILE: packages/cli/src/config/settingPaths.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { SettingPaths } from './settingPaths.js'; describe('SettingPaths', () => { it('should have the correct structure', () => { expect(SettingPaths).toEqual({ General: { PreferredEditor: 'general.preferredEditor', }, }); }); it('should be immutable', () => { expect(Object.isFrozen(SettingPaths)).toBe(false); // It's not frozen by default in JS unless Object.freeze is called, but it's `as const` in TS. // However, we can check if the values are correct. expect(SettingPaths.General.PreferredEditor).toBe( 'general.preferredEditor', ); }); }); ================================================ FILE: packages/cli/src/config/settingPaths.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export const SettingPaths = { General: { PreferredEditor: 'general.preferredEditor', }, } as const; ================================================ FILE: packages/cli/src/config/settings-validation.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// import { describe, it, expect } from 'vitest'; import { validateSettings, formatValidationError, settingsZodSchema, } from './settings-validation.js'; import { z } from 'zod'; describe('settings-validation', () => { describe('validateSettings', () => { it('should accept valid settings with correct model.name as string', () => { const validSettings = { model: { name: 'gemini-2.0-flash-exp', maxSessionTurns: 10, }, ui: { theme: 'dark', }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should reject model.name as object instead of string', () => { const invalidSettings = { model: { name: { skipNextSpeakerCheck: true, }, }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); expect(result.error).toBeDefined(); if (result.error) { const issues = result.error.issues; expect(issues.length).toBeGreaterThan(0); expect(issues[0]?.path).toEqual(['model', 'name']); expect(issues[0]?.code).toBe('invalid_type'); } }); it('should accept valid model.summarizeToolOutput structure', () => { const validSettings = { model: { summarizeToolOutput: { run_shell_command: { tokenBudget: 500, }, }, }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should reject invalid model.summarizeToolOutput structure', () => { const invalidSettings = { model: { summarizeToolOutput: { run_shell_command: { tokenBudget: 500, }, }, }, }; // First test with valid structure let result = validateSettings(invalidSettings); expect(result.success).toBe(true); // Now test with wrong type (string instead of object) const actuallyInvalidSettings = { model: { summarizeToolOutput: 'invalid', }, }; result = validateSettings(actuallyInvalidSettings); expect(result.success).toBe(false); if (result.error) { expect(result.error.issues.length).toBeGreaterThan(0); } }); it('should accept empty settings object', () => { const emptySettings = {}; const result = validateSettings(emptySettings); expect(result.success).toBe(true); }); it('should accept unknown top-level keys (for migration compatibility)', () => { const settingsWithUnknownKey = { unknownKey: 'some value', }; const result = validateSettings(settingsWithUnknownKey); expect(result.success).toBe(true); // Unknown keys are allowed via .passthrough() for migration scenarios }); it('should accept nested valid settings', () => { const validSettings = { ui: { theme: 'dark', hideWindowTitle: true, footer: { hideCWD: false, hideModelInfo: true, }, }, tools: { sandbox: 'inherit', }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should validate array types correctly', () => { const validSettings = { tools: { allowed: ['git', 'npm'], exclude: ['dangerous-tool'], }, context: { includeDirectories: ['/path/1', '/path/2'], }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should reject invalid types in arrays', () => { const invalidSettings = { tools: { allowed: ['git', 123], }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); }); it('should validate boolean fields correctly', () => { const validSettings = { general: { vimMode: true, disableAutoUpdate: false, }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should reject non-boolean values for boolean fields', () => { const invalidSettings = { general: { vimMode: 'yes', }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); }); it('should validate number fields correctly', () => { const validSettings = { model: { maxSessionTurns: 50, compressionThreshold: 0.2, }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should validate complex nested mcpServers configuration', () => { const invalidSettings = { mcpServers: { 'my-server': { command: 123, // Should be string args: ['arg1'], env: { VAR: 'value', }, }, }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { expect(result.error.issues.length).toBeGreaterThan(0); // Path should be mcpServers.my-server.command const issue = result.error.issues.find((i) => i.path.includes('command'), ); expect(issue).toBeDefined(); expect(issue?.code).toBe('invalid_type'); } }); it('should validate mcpServers with type field for all transport types', () => { const validSettings = { mcpServers: { 'sse-server': { url: 'https://example.com/sse', type: 'sse', headers: { 'X-API-Key': 'key' }, }, 'http-server': { url: 'https://example.com/mcp', type: 'http', }, 'stdio-server': { command: '/usr/bin/mcp-server', type: 'stdio', }, }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should reject invalid type values in mcpServers', () => { const invalidSettings = { mcpServers: { 'bad-server': { url: 'https://example.com/mcp', type: 'invalid-type', }, }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); }); it('should validate mcpServers without type field', () => { const validSettings = { mcpServers: { 'stdio-server': { command: '/usr/bin/mcp-server', args: ['--port', '8080'], }, 'url-server': { url: 'https://example.com/mcp', }, }, }; const result = validateSettings(validSettings); expect(result.success).toBe(true); }); it('should validate complex nested customThemes configuration', () => { const invalidSettings = { ui: { customThemes: { 'my-theme': { type: 'custom', // Missing 'name' property which is required text: { primary: '#ffffff', }, }, }, }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { expect(result.error.issues.length).toBeGreaterThan(0); // Should complain about missing 'name' const issue = result.error.issues.find( (i) => i.code === 'invalid_type' && i.message.includes('Required'), ); expect(issue).toBeDefined(); } }); }); describe('formatValidationError', () => { it('should format error with file path and helpful message for model.name', () => { const invalidSettings = { model: { name: { skipNextSpeakerCheck: true, }, }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError( result.error, '/path/to/settings.json', ); expect(formatted).toContain('/path/to/settings.json'); expect(formatted).toContain('model.name'); expect(formatted).toContain('Expected: string, but received: object'); expect(formatted).toContain('Please fix the configuration.'); expect(formatted).toContain( 'https://geminicli.com/docs/reference/configuration/', ); } }); it('should format error for model.summarizeToolOutput', () => { const invalidSettings = { model: { summarizeToolOutput: 'wrong type', }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError( result.error, '~/.gemini/settings.json', ); expect(formatted).toContain('~/.gemini/settings.json'); expect(formatted).toContain('model.summarizeToolOutput'); } }); it('should include link to documentation', () => { const invalidSettings = { model: { name: { invalid: 'object' }, // model.name should be a string }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError(result.error, 'test.json'); expect(formatted).toContain( 'https://geminicli.com/docs/reference/configuration/', ); } }); it('should list all validation errors', () => { const invalidSettings = { model: { name: { invalid: 'object' }, maxSessionTurns: 'not a number', }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError(result.error, 'test.json'); // Should have multiple errors listed expect(formatted.match(/Error in:/g)?.length).toBeGreaterThan(1); } }); it('should format array paths correctly (e.g. tools.allowed[0])', () => { const invalidSettings = { tools: { allowed: ['git', 123], // 123 is invalid, expected string }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError(result.error, 'test.json'); expect(formatted).toContain('tools.allowed[1]'); } }); it('should limit the number of displayed errors', () => { const invalidSettings = { tools: { // Create 6 invalid items to trigger the limit allowed: [1, 2, 3, 4, 5, 6], }, }; const result = validateSettings(invalidSettings); expect(result.success).toBe(false); if (result.error) { const formatted = formatValidationError(result.error, 'test.json'); // Should see the first 5 expect(formatted).toContain('tools.allowed[0]'); expect(formatted).toContain('tools.allowed[4]'); // Should NOT see the 6th expect(formatted).not.toContain('tools.allowed[5]'); // Should see the summary expect(formatted).toContain('...and 1 more errors.'); } }); }); describe('settingsZodSchema', () => { it('should be a valid Zod object schema', () => { expect(settingsZodSchema).toBeInstanceOf(z.ZodObject); }); it('should have optional fields', () => { // All top-level fields should be optional const shape = settingsZodSchema.shape; expect(shape['model']).toBeDefined(); expect(shape['ui']).toBeDefined(); expect(shape['tools']).toBeDefined(); // Test that empty object is valid (all fields optional) const result = settingsZodSchema.safeParse({}); expect(result.success).toBe(true); }); }); }); ================================================ FILE: packages/cli/src/config/settings-validation.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { z } from 'zod'; import { getSettingsSchema, type SettingDefinition, type SettingCollectionDefinition, SETTINGS_SCHEMA_DEFINITIONS, } from './settingsSchema.js'; // Helper to build Zod schema from the JSON-schema-like definitions // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny { if (def.anyOf) { return z.union( // eslint-disable-next-line @typescript-eslint/no-explicit-any def.anyOf.map((d: any) => buildZodSchemaFromJsonSchema(d)), ); } if (def.type === 'string') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if (def.enum) return z.enum(def.enum as [string, ...string[]]); return z.string(); } if (def.type === 'number') return z.number(); if (def.type === 'boolean') return z.boolean(); if (def.type === 'array') { if (def.items) { return z.array(buildZodSchemaFromJsonSchema(def.items)); } return z.array(z.unknown()); } if (def.type === 'object') { let schema; if (def.properties) { const shape: Record = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion for (const [key, propDef] of Object.entries(def.properties) as any) { let propSchema = buildZodSchemaFromJsonSchema(propDef); if ( def.required && Array.isArray(def.required) && def.required.includes(key) ) { // keep it required } else { propSchema = propSchema.optional(); } shape[key] = propSchema; } schema = z.object(shape).passthrough(); } else { schema = z.object({}).passthrough(); } if (def.additionalProperties === false) { schema = schema.strict(); } else if (typeof def.additionalProperties === 'object') { schema = schema.catchall( buildZodSchemaFromJsonSchema(def.additionalProperties), ); } return schema; } return z.unknown(); } /** * Builds a Zod enum schema from options array */ function buildEnumSchema( options: ReadonlyArray<{ value: string | number | boolean; label: string }>, ): z.ZodTypeAny { if (!options || options.length === 0) { throw new Error( `Enum type must have options defined. Check your settings schema definition.`, ); } const values = options.map((opt) => opt.value); if (values.every((v) => typeof v === 'string')) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return z.enum(values as [string, ...string[]]); } else if (values.every((v) => typeof v === 'number')) { return z.union( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion values.map((v) => z.literal(v)) as [ z.ZodLiteral, z.ZodLiteral, ...Array>, ], ); } else { return z.union( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion values.map((v) => z.literal(v)) as [ z.ZodLiteral, z.ZodLiteral, ...Array>, ], ); } } /** * Builds a Zod object shape from properties record */ function buildObjectShapeFromProperties( properties: Record, ): Record { const shape: Record = {}; for (const [key, childDef] of Object.entries(properties)) { shape[key] = buildZodSchemaFromDefinition(childDef); } return shape; } /** * Builds a Zod schema for primitive types (string, number, boolean) */ function buildPrimitiveSchema( type: 'string' | 'number' | 'boolean', ): z.ZodTypeAny { switch (type) { case 'string': return z.string(); case 'number': return z.number(); case 'boolean': return z.boolean(); default: return z.unknown(); } } const REF_SCHEMAS: Record = {}; // Initialize REF_SCHEMAS for (const [name, def] of Object.entries(SETTINGS_SCHEMA_DEFINITIONS)) { REF_SCHEMAS[name] = buildZodSchemaFromJsonSchema(def); } /** * Recursively builds a Zod schema from a SettingDefinition */ function buildZodSchemaFromDefinition( definition: SettingDefinition, ): z.ZodTypeAny { let baseSchema: z.ZodTypeAny; // Special handling for TelemetrySettings which can be boolean or object if (definition.ref === 'TelemetrySettings') { const objectSchema = REF_SCHEMAS['TelemetrySettings']; if (objectSchema) { return z.union([z.boolean(), objectSchema]).optional(); } } // Handle refs using registry if (definition.ref && definition.ref in REF_SCHEMAS) { return REF_SCHEMAS[definition.ref].optional(); } switch (definition.type) { case 'string': case 'number': case 'boolean': baseSchema = buildPrimitiveSchema(definition.type); break; case 'enum': { baseSchema = buildEnumSchema(definition.options!); break; } case 'array': if (definition.items) { const itemSchema = buildZodSchemaFromCollection(definition.items); baseSchema = z.array(itemSchema); } else { baseSchema = z.array(z.unknown()); } break; case 'object': if (definition.properties) { const shape = buildObjectShapeFromProperties(definition.properties); baseSchema = z.object(shape).passthrough(); if (definition.additionalProperties) { const additionalSchema = buildZodSchemaFromCollection( definition.additionalProperties, ); baseSchema = z.object(shape).catchall(additionalSchema); } } else if (definition.additionalProperties) { const valueSchema = buildZodSchemaFromCollection( definition.additionalProperties, ); baseSchema = z.record(z.string(), valueSchema); } else { baseSchema = z.record(z.string(), z.unknown()); } break; default: baseSchema = z.unknown(); } // Make all fields optional since settings are partial return baseSchema.optional(); } /** * Builds a Zod schema from a SettingCollectionDefinition */ function buildZodSchemaFromCollection( collection: SettingCollectionDefinition, ): z.ZodTypeAny { if (collection.ref && collection.ref in REF_SCHEMAS) { return REF_SCHEMAS[collection.ref]; } switch (collection.type) { case 'string': case 'number': case 'boolean': return buildPrimitiveSchema(collection.type); case 'enum': { return buildEnumSchema(collection.options!); } case 'array': if (collection.properties) { const shape = buildObjectShapeFromProperties(collection.properties); return z.array(z.object(shape)); } return z.array(z.unknown()); case 'object': if (collection.properties) { const shape = buildObjectShapeFromProperties(collection.properties); return z.object(shape).passthrough(); } return z.record(z.string(), z.unknown()); default: return z.unknown(); } } /** * Builds the complete Zod schema for Settings from SETTINGS_SCHEMA */ function buildSettingsZodSchema(): z.ZodObject> { const schema = getSettingsSchema(); const shape: Record = {}; for (const [key, definition] of Object.entries(schema)) { shape[key] = buildZodSchemaFromDefinition(definition); } return z.object(shape).passthrough(); } export const settingsZodSchema = buildSettingsZodSchema(); /** * Validates settings data against the Zod schema */ export function validateSettings(data: unknown): { success: boolean; data?: unknown; error?: z.ZodError; } { const result = settingsZodSchema.safeParse(data); return result; } /** * Format a Zod error into a helpful error message */ export function formatValidationError( error: z.ZodError, filePath: string, ): string { const lines: string[] = []; lines.push(`Invalid configuration in ${filePath}:`); lines.push(''); const MAX_ERRORS_TO_DISPLAY = 5; const displayedIssues = error.issues.slice(0, MAX_ERRORS_TO_DISPLAY); for (const issue of displayedIssues) { const path = issue.path.reduce( (acc, curr) => typeof curr === 'number' ? `${acc}[${curr}]` : `${acc ? acc + '.' : ''}${curr}`, '', ); lines.push(`Error in: ${path || '(root)'}`); lines.push(` ${issue.message}`); if (issue.code === 'invalid_type') { const expected = issue.expected; const received = issue.received; lines.push(`Expected: ${expected}, but received: ${received}`); } lines.push(''); } if (error.issues.length > MAX_ERRORS_TO_DISPLAY) { lines.push( `...and ${error.issues.length - MAX_ERRORS_TO_DISPLAY} more errors.`, ); lines.push(''); } lines.push('Please fix the configuration.'); lines.push('See: https://geminicli.com/docs/reference/configuration/'); return lines.join('\n'); } ================================================ FILE: packages/cli/src/config/settings.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// // Mock 'os' first. import * as osActual from 'node:os'; // Import for type info for the mock factory vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, homedir: vi.fn(() => path.resolve('/mock/home/user')), platform: vi.fn(() => 'linux'), }; }); // Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants. vi.mock('./settings.js', async (importActual) => { const originalModule = await importActual(); return { __esModule: true, // Ensure correct module shape ...originalModule, // Re-export all original members // We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir() }; }); // Mock trustedFolders import * as trustedFolders from './trustedFolders.js'; vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(), isFolderTrustEnabled: vi.fn(), loadTrustedFolders: vi.fn(), })); vi.mock('./settingsSchema.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getSettingsSchema: vi.fn(actual.getSettingsSchema), }; }); // NOW import everything else, including the (now effectively re-exported) settings.js import * as path from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH import { describe, it, expect, vi, beforeEach, afterEach, type Mocked, type Mock, } from 'vitest'; import * as fs from 'node:fs'; // fs will be mocked separately import stripJsonComments from 'strip-json-comments'; // Will be mocked separately import { isWorkspaceTrusted } from './trustedFolders.js'; // These imports will get the versions from the vi.mock('./settings.js', ...) factory. import { loadSettings, USER_SETTINGS_PATH, // This IS the mocked path. getSystemSettingsPath, getSystemDefaultsPath, type Settings, type SettingsFile, saveSettings, getDefaultsFromSchema, loadEnvironment, migrateDeprecatedSettings, SettingScope, LoadedSettings, sanitizeEnvVar, createTestMergedSettings, resetSettingsCacheForTesting, } from './settings.js'; import { FatalConfigError, GEMINI_DIR, Storage, type MCPServerConfig, } from '@google/gemini-cli-core'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { getSettingsSchema, MergeStrategy, type SettingsSchema, } from './settingsSchema.js'; import { createMockSettings } from '../test-utils/settings.js'; const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace')); // Use the (mocked) GEMINI_DIR for consistency const MOCK_WORKSPACE_SETTINGS_PATH = path.join( MOCK_WORKSPACE_DIR, GEMINI_DIR, 'settings.json', ); // A more flexible type for test data that allows arbitrary properties. type TestSettings = Settings & { [key: string]: unknown }; // Helper to normalize paths for test assertions, making them OS-agnostic const normalizePath = (p: string | fs.PathOrFileDescriptor) => path.normalize(p.toString()); vi.mock('fs', async (importOriginal) => { // Get all the functions from the real 'fs' module const actualFs = await importOriginal(); return { ...actualFs, // Keep all the real functions // Now, just override the ones we need for the test existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), realpathSync: vi.fn((p: string) => p), }; }); vi.mock('./extension.js'); const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), emitSettingsChanged: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); const os = await import('node:os'); const pathMod = await import('node:path'); const fsMod = await import('node:fs'); // Helper to resolve paths using the test's mocked environment const testResolve = (p: string | undefined) => { if (!p) return ''; try { // Use the mocked fs.realpathSync if available, otherwise fallback return fsMod.realpathSync(pathMod.resolve(p)); } catch { return pathMod.resolve(p); } }; // Create a smarter mock for isWorkspaceHomeDir vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation( function (this: Storage) { const target = testResolve(pathMod.dirname(this.getGeminiDir())); // Pick up the mocked home directory specifically from the 'os' mock const home = testResolve(os.homedir()); return actual.normalizePath(target) === actual.normalizePath(home); }, ); return { ...actual, coreEvents: mockCoreEvents, homedir: vi.fn(() => os.homedir()), }; }); vi.mock('../utils/commentJson.js', () => ({ updateSettingsFilePreservingFormat: vi.fn(), })); vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); describe('Settings Loading and Merging', () => { let mockFsExistsSync: Mocked; let mockStripJsonComments: Mocked; let mockFsMkdirSync: Mocked; beforeEach(() => { vi.resetAllMocks(); resetSettingsCacheForTesting(); mockFsExistsSync = vi.mocked(fs.existsSync); mockFsMkdirSync = vi.mocked(fs.mkdirSync); mockStripJsonComments = vi.mocked(stripJsonComments); vi.mocked(osActual.homedir).mockReturnValue( path.resolve('/mock/home/user'), ); (mockStripJsonComments as unknown as Mock).mockImplementation( (jsonString: string) => jsonString, ); (mockFsExistsSync as Mock).mockReturnValue(false); (fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON (mockFsMkdirSync as Mock).mockImplementation(() => undefined); vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: true, source: 'file', }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('loadSettings', () => { it.each([ { scope: 'system', path: getSystemSettingsPath(), content: { ui: { theme: 'system-default' }, tools: { sandbox: false }, }, }, { scope: 'user', path: USER_SETTINGS_PATH, content: { ui: { theme: 'dark' }, context: { fileName: 'USER_CONTEXT.md' }, }, }, { scope: 'workspace', path: MOCK_WORKSPACE_SETTINGS_PATH, content: { tools: { sandbox: true }, context: { fileName: 'WORKSPACE_CONTEXT.md' }, }, }, ])( 'should load $scope settings if only $scope file exists', ({ scope, path: p, content }) => { (mockFsExistsSync as Mock).mockImplementation( (pathLike: fs.PathLike) => path.normalize(pathLike.toString()) === path.normalize(p), ); (fs.readFileSync as Mock).mockImplementation( (pathDesc: fs.PathOrFileDescriptor) => { if (path.normalize(pathDesc.toString()) === path.normalize(p)) return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(fs.readFileSync).toHaveBeenCalledWith( expect.stringContaining(path.basename(p)), 'utf-8', ); expect( settings[scope as 'system' | 'user' | 'workspace'].settings, ).toEqual(content); expect(settings.merged).toMatchObject(content); }, ); it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { const normP = path.normalize(p.toString()); return ( normP === path.normalize(getSystemSettingsPath()) || normP === path.normalize(USER_SETTINGS_PATH) || normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH) ); }); const systemSettingsContent = { ui: { theme: 'system-theme', }, tools: { sandbox: false, }, mcp: { allowed: ['server1', 'server2'], }, telemetry: { enabled: false }, }; const userSettingsContent = { ui: { theme: 'dark', }, tools: { sandbox: true, }, context: { fileName: 'USER_CONTEXT.md', }, }; const workspaceSettingsContent = { tools: { sandbox: false, core: ['tool1'], }, context: { fileName: 'WORKSPACE_CONTEXT.md', }, mcp: { allowed: ['server1', 'server2', 'server3'], }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { const normP = path.normalize(p.toString()); if (normP === path.normalize(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normP === path.normalize(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toMatchObject({ ui: { theme: 'system-theme', }, tools: { sandbox: false, core: ['tool1'], }, telemetry: { enabled: false }, context: { fileName: 'WORKSPACE_CONTEXT.md', }, mcp: { allowed: ['server1', 'server2'], }, }); }); it('should merge all settings files with the correct precedence', () => { // Mock schema to test defaults application const mockSchema = { ui: { type: 'object', default: {}, properties: {} }, tools: { type: 'object', default: {}, properties: {} }, context: { type: 'object', default: {}, properties: { discoveryMaxDirs: { type: 'number', default: 200 }, includeDirectories: { type: 'array', default: [], mergeStrategy: MergeStrategy.CONCAT, }, }, }, mcpServers: { type: 'object', default: {} }, }; (getSettingsSchema as Mock).mockReturnValue( mockSchema as unknown as SettingsSchema, ); (mockFsExistsSync as Mock).mockReturnValue(true); const systemDefaultsContent = { ui: { theme: 'default-theme', }, tools: { sandbox: true, }, telemetry: true, context: { includeDirectories: ['/system/defaults/dir'], }, }; const userSettingsContent = { ui: { theme: 'user-theme', }, context: { fileName: 'USER_CONTEXT.md', includeDirectories: ['/user/dir1', '/user/dir2'], }, }; const workspaceSettingsContent = { tools: { sandbox: false, }, context: { fileName: 'WORKSPACE_CONTEXT.md', includeDirectories: ['/workspace/dir'], }, }; const systemSettingsContent = { ui: { theme: 'system-theme', }, telemetry: false, context: { includeDirectories: ['/system/dir'], }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.systemDefaults.settings).toEqual(systemDefaultsContent); expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ context: { discoveryMaxDirs: 200, includeDirectories: [ '/system/defaults/dir', '/user/dir1', '/user/dir2', '/workspace/dir', '/system/dir', ], fileName: 'WORKSPACE_CONTEXT.md', }, mcpServers: {}, ui: { theme: 'system-theme' }, tools: { sandbox: false }, telemetry: false, }); }); it('should use folderTrust from workspace settings when trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { security: { folderTrust: { enabled: true, }, }, }; const workspaceSettingsContent = { security: { folderTrust: { enabled: false, // This should be used }, }, }; const systemSettingsContent = { // No folderTrust here }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used }); it('should use system folderTrust over user setting', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { security: { folderTrust: { enabled: false, }, }, }; const workspaceSettingsContent = { security: { folderTrust: { enabled: true, // This should be ignored }, }, }; const systemSettingsContent = { security: { folderTrust: { enabled: true, }, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // System setting should be used }); it('should not allow user or workspace to override system disableYoloMode', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { security: { disableYoloMode: false, disableAlwaysAllow: false, }, }; const workspaceSettingsContent = { security: { disableYoloMode: false, // This should be ignored disableAlwaysAllow: false, // This should be ignored }, }; const systemSettingsContent = { security: { disableYoloMode: true, disableAlwaysAllow: true, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used }); it.each([ { description: 'contextFileName in user settings', path: USER_SETTINGS_PATH, content: { context: { fileName: 'CUSTOM.md' } }, expected: { key: 'context.fileName', value: 'CUSTOM.md' }, }, { description: 'contextFileName in workspace settings', path: MOCK_WORKSPACE_SETTINGS_PATH, content: { context: { fileName: 'PROJECT_SPECIFIC.md' } }, expected: { key: 'context.fileName', value: 'PROJECT_SPECIFIC.md' }, }, { description: 'excludedProjectEnvVars in user settings', path: USER_SETTINGS_PATH, content: { advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] }, }, expected: { key: 'advanced.excludedEnvVars', value: ['DEBUG', 'DEBUG_MODE', 'NODE_ENV', 'CUSTOM_VAR'], }, }, { description: 'excludedProjectEnvVars in workspace settings', path: MOCK_WORKSPACE_SETTINGS_PATH, content: { advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }, expected: { key: 'advanced.excludedEnvVars', value: ['DEBUG', 'DEBUG_MODE', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR'], }, }, ])( 'should handle $description correctly', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(path)) return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); const keys = expected.key.split('.'); let result: unknown = settings.merged; for (const key of keys) { result = (result as { [key: string]: unknown })[key]; } expect(result).toEqual(expected.value); }, ); it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { general: {}, advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, }; const workspaceSettingsContent = { general: {}, advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', 'USER_VAR', ]); expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'DEBUG_MODE', 'NODE_ENV', 'USER_VAR', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); }); it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { ui: { theme: 'dark' } }; const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.context?.fileName).toBeUndefined(); }); it.each([ { scope: 'user', path: USER_SETTINGS_PATH, content: { telemetry: { enabled: true } }, expected: true, }, { scope: 'workspace', path: MOCK_WORKSPACE_SETTINGS_PATH, content: { telemetry: { enabled: false } }, expected: false, }, ])( 'should load telemetry setting from $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(path)) return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.telemetry?.enabled).toBe(expected); }, ); it('should prioritize workspace telemetry setting over user setting', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { telemetry: { enabled: true } }; const workspaceSettingsContent = { telemetry: { enabled: false } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.telemetry?.enabled).toBe(false); }); it('should have telemetry as undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.telemetry).toBeUndefined(); expect(settings.merged.ui).toBeDefined(); expect(settings.merged.mcpServers).toEqual({}); }); it('should merge MCP servers correctly, with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { mcpServers: { 'user-server': { command: 'user-command', args: ['--user-arg'], description: 'User MCP server', }, 'shared-server': { command: 'user-shared-command', description: 'User shared server config', }, }, }; const workspaceSettingsContent = { mcpServers: { 'workspace-server': { command: 'workspace-command', args: ['--workspace-arg'], description: 'Workspace MCP server', }, 'shared-server': { command: 'workspace-shared-command', description: 'Workspace shared server config', }, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged.mcpServers).toEqual({ 'user-server': { command: 'user-command', args: ['--user-arg'], description: 'User MCP server', }, 'workspace-server': { command: 'workspace-command', args: ['--workspace-arg'], description: 'Workspace MCP server', }, 'shared-server': { command: 'workspace-shared-command', description: 'Workspace shared server config', }, }); }); it.each([ { scope: 'user', path: USER_SETTINGS_PATH, content: { mcpServers: { 'user-only-server': { command: 'user-only-command', description: 'User only server', }, }, }, expected: { 'user-only-server': { command: 'user-only-command', description: 'User only server', }, }, }, { scope: 'workspace', path: MOCK_WORKSPACE_SETTINGS_PATH, content: { mcpServers: { 'workspace-only-server': { command: 'workspace-only-command', description: 'Workspace only server', }, }, }, expected: { 'workspace-only-server': { command: 'workspace-only-command', description: 'Workspace only server', }, }, }, ])( 'should handle MCP servers when only in $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(path)) return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcpServers).toEqual(expected); }, ); it('should have mcpServers as undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcpServers).toEqual({}); }); it('should merge MCP servers from system, user, and workspace with system taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { mcpServers: { 'shared-server': { command: 'system-command', args: ['--system-arg'], }, 'system-only-server': { command: 'system-only-command', }, }, }; const userSettingsContent = { mcpServers: { 'user-server': { command: 'user-command', }, 'shared-server': { command: 'user-command', description: 'from user', }, }, }; const workspaceSettingsContent = { mcpServers: { 'workspace-server': { command: 'workspace-command', }, 'shared-server': { command: 'workspace-command', args: ['--workspace-arg'], }, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcpServers).toEqual({ 'user-server': { command: 'user-command', }, 'workspace-server': { command: 'workspace-command', }, 'system-only-server': { command: 'system-only-command', }, 'shared-server': { command: 'system-command', args: ['--system-arg'], }, }); }); it('should merge mcp allowed/excluded lists with system taking precedence over workspace', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { mcp: { allowed: ['system-allowed'], }, }; const userSettingsContent = { mcp: { allowed: ['user-allowed'], excluded: ['user-excluded'], }, }; const workspaceSettingsContent = { mcp: { allowed: ['workspace-allowed'], excluded: ['workspace-excluded'], }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcp).toEqual({ allowed: ['system-allowed'], excluded: ['workspace-excluded'], }); }); describe('compressionThreshold settings', () => { it.each([ { description: 'should be taken from user settings if only present there', userContent: { model: { compressionThreshold: 0.5 } }, workspaceContent: {}, expected: 0.5, }, { description: 'should be taken from workspace settings if only present there', userContent: {}, workspaceContent: { model: { compressionThreshold: 0.8 } }, expected: 0.8, }, { description: 'should prioritize workspace settings over user settings', userContent: { model: { compressionThreshold: 0.5 } }, workspaceContent: { model: { compressionThreshold: 0.8 } }, expected: 0.8, }, { description: 'should be default if not in any settings file', userContent: {}, workspaceContent: {}, expected: 0.5, }, ])('$description', ({ userContent, workspaceContent, expected }) => { (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userContent); if ( normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) ) return JSON.stringify(workspaceContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.model?.compressionThreshold).toEqual(expected); }); }); it('should use user compressionThreshold if workspace does not define it', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { general: {}, model: { compressionThreshold: 0.5 }, }; const workspaceSettingsContent = { general: {}, model: {}, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.model?.compressionThreshold).toEqual(0.5); }); it('should merge includeDirectories from all scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { context: { includeDirectories: ['/system/dir'] }, }; const systemDefaultsContent = { context: { includeDirectories: ['/system/defaults/dir'] }, }; const userSettingsContent = { context: { includeDirectories: ['/user/dir1', '/user/dir2'] }, }; const workspaceSettingsContent = { context: { includeDirectories: ['/workspace/dir'] }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.context?.includeDirectories).toEqual([ '/system/defaults/dir', '/user/dir1', '/user/dir2', '/workspace/dir', '/system/dir', ]); }); it('should handle JSON parsing errors gracefully', () => { (mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist" const invalidJsonContent = 'invalid json'; const userReadError = new SyntaxError( "Expected ',' or '}' after property value in JSON at position 10", ); const workspaceReadError = new SyntaxError( 'Unexpected token i in JSON at position 0', ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { // Simulate JSON.parse throwing for user settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw userReadError; }); return invalidJsonContent; // Content that would cause JSON.parse to throw } if ( normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) ) { // Simulate JSON.parse throwing for workspace settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw workspaceReadError; }); return invalidJsonContent; } return '{}'; // Default for other reads }, ); try { loadSettings(MOCK_WORKSPACE_DIR); throw new Error('loadSettings should have thrown a FatalConfigError'); } catch (e) { expect(e).toBeInstanceOf(FatalConfigError); const error = e as FatalConfigError; expect(error.message).toContain( `Error in ${USER_SETTINGS_PATH}: ${userReadError.message}`, ); expect(error.message).toContain( `Error in ${MOCK_WORKSPACE_SETTINGS_PATH}: ${workspaceReadError.message}`, ); expect(error.message).toContain( 'Please fix the configuration file(s) and try again.', ); } // Restore JSON.parse mock if it was spied on specifically for this test vi.restoreAllMocks(); // Or more targeted restore if needed }); it('should resolve environment variables in user settings', () => { process.env['TEST_API_KEY'] = 'user_api_key_from_env'; const userSettingsContent: TestSettings = { apiKey: '$TEST_API_KEY', someUrl: 'https://test.com/${TEST_API_KEY}', }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['apiKey']).toBe( 'user_api_key_from_env', ); expect((settings.user.settings as TestSettings)['someUrl']).toBe( 'https://test.com/user_api_key_from_env', ); expect((settings.merged as TestSettings)['apiKey']).toBe( 'user_api_key_from_env', ); delete process.env['TEST_API_KEY']; }); it('should resolve environment variables in workspace settings', () => { process.env['WORKSPACE_ENDPOINT'] = 'workspace_endpoint_from_env'; const workspaceSettingsContent: TestSettings = { endpoint: '${WORKSPACE_ENDPOINT}/api', nested: { value: '$WORKSPACE_ENDPOINT' }, }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.workspace.settings as TestSettings)['endpoint']).toBe( 'workspace_endpoint_from_env/api', ); const nested = (settings.workspace.settings as TestSettings)[ 'nested' ] as Record; expect(nested['value']).toBe('workspace_endpoint_from_env'); expect((settings.merged as TestSettings)['endpoint']).toBe( 'workspace_endpoint_from_env/api', ); delete process.env['WORKSPACE_ENDPOINT']; }); it('should correctly resolve and merge env variables from different scopes', () => { process.env['SYSTEM_VAR'] = 'system_value'; process.env['USER_VAR'] = 'user_value'; process.env['WORKSPACE_VAR'] = 'workspace_value'; process.env['SHARED_VAR'] = 'final_value'; const systemSettingsContent: TestSettings = { configValue: '$SHARED_VAR', systemOnly: '$SYSTEM_VAR', }; const userSettingsContent: TestSettings = { configValue: '$SHARED_VAR', userOnly: '$USER_VAR', ui: { theme: 'dark', }, }; const workspaceSettingsContent: TestSettings = { configValue: '$SHARED_VAR', workspaceOnly: '$WORKSPACE_VAR', ui: { theme: 'light', }, }; (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { return JSON.stringify(userSettingsContent); } if ( normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); // Check resolved values in individual scopes expect((settings.system.settings as TestSettings)['configValue']).toBe( 'final_value', ); expect((settings.system.settings as TestSettings)['systemOnly']).toBe( 'system_value', ); expect((settings.user.settings as TestSettings)['configValue']).toBe( 'final_value', ); expect((settings.user.settings as TestSettings)['userOnly']).toBe( 'user_value', ); expect((settings.workspace.settings as TestSettings)['configValue']).toBe( 'final_value', ); expect( (settings.workspace.settings as TestSettings)['workspaceOnly'], ).toBe('workspace_value'); // Check merged values (system > workspace > user) expect((settings.merged as TestSettings)['configValue']).toBe( 'final_value', ); expect((settings.merged as TestSettings)['systemOnly']).toBe( 'system_value', ); expect((settings.merged as TestSettings)['userOnly']).toBe('user_value'); expect((settings.merged as TestSettings)['workspaceOnly']).toBe( 'workspace_value', ); expect(settings.merged.ui?.theme).toBe('light'); // workspace overrides user delete process.env['SYSTEM_VAR']; delete process.env['USER_VAR']; delete process.env['WORKSPACE_VAR']; delete process.env['SHARED_VAR']; }); it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { advanced: { dnsResolutionOrder: 'ipv4first' }, }; const workspaceSettingsContent = { advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); }); it('should use user dnsResolutionOrder if workspace is not defined', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.advanced?.dnsResolutionOrder).toBe('verbatim'); }); it('should leave unresolved environment variables as is', () => { const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['apiKey']).toBe( '$UNDEFINED_VAR', ); expect((settings.merged as TestSettings)['apiKey']).toBe( '$UNDEFINED_VAR', ); }); it('should resolve multiple environment variables in a single string', () => { process.env['VAR_A'] = 'valueA'; process.env['VAR_B'] = 'valueB'; const userSettingsContent: TestSettings = { path: '/path/$VAR_A/${VAR_B}/end', }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['path']).toBe( '/path/valueA/valueB/end', ); delete process.env['VAR_A']; delete process.env['VAR_B']; }); it('should resolve environment variables in arrays', () => { process.env['ITEM_1'] = 'item1_env'; process.env['ITEM_2'] = 'item2_env'; const userSettingsContent: TestSettings = { list: ['$ITEM_1', '${ITEM_2}', 'literal'], }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['list']).toEqual([ 'item1_env', 'item2_env', 'literal', ]); delete process.env['ITEM_1']; delete process.env['ITEM_2']; }); it('should correctly pass through null, boolean, and number types, and handle undefined properties', () => { process.env['MY_ENV_STRING'] = 'env_string_value'; process.env['MY_ENV_STRING_NESTED'] = 'env_string_nested_value'; const userSettingsContent: TestSettings = { nullVal: null, trueVal: true, falseVal: false, numberVal: 123.45, stringVal: '$MY_ENV_STRING', nestedObj: { nestedNull: null, nestedBool: true, nestedNum: 0, nestedString: 'literal', anotherEnv: '${MY_ENV_STRING_NESTED}', }, }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['nullVal']).toBeNull(); expect((settings.user.settings as TestSettings)['trueVal']).toBe(true); expect((settings.user.settings as TestSettings)['falseVal']).toBe(false); expect((settings.user.settings as TestSettings)['numberVal']).toBe( 123.45, ); expect((settings.user.settings as TestSettings)['stringVal']).toBe( 'env_string_value', ); expect( (settings.user.settings as TestSettings)['undefinedVal'], ).toBeUndefined(); const nestedObj = (settings.user.settings as TestSettings)[ 'nestedObj' ] as Record; expect(nestedObj['nestedNull']).toBeNull(); expect(nestedObj['nestedBool']).toBe(true); expect(nestedObj['nestedNum']).toBe(0); expect(nestedObj['nestedString']).toBe('literal'); expect(nestedObj['anotherEnv']).toBe('env_string_nested_value'); delete process.env['MY_ENV_STRING']; delete process.env['MY_ENV_STRING_NESTED']; }); it('should resolve multiple concatenated environment variables in a single string value', () => { process.env['TEST_HOST'] = 'myhost'; process.env['TEST_PORT'] = '9090'; const userSettingsContent: TestSettings = { serverAddress: '${TEST_HOST}:${TEST_PORT}/api', }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect((settings.user.settings as TestSettings)['serverAddress']).toBe( 'myhost:9090/api', ); delete process.env['TEST_HOST']; delete process.env['TEST_PORT']; }); describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => { const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve( '/mock/env/system/settings.json', ); beforeEach(() => { process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = MOCK_ENV_SYSTEM_SETTINGS_PATH; }); afterEach(() => { delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; }); it('should load system settings from the path specified in the environment variable', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH, ); const systemSettingsContent = { ui: { theme: 'env-var-theme' }, tools: { sandbox: true }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (p === MOCK_ENV_SYSTEM_SETTINGS_PATH) return JSON.stringify(systemSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(fs.readFileSync).toHaveBeenCalledWith( MOCK_ENV_SYSTEM_SETTINGS_PATH, 'utf-8', ); expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH); expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.merged).toMatchObject({ ...systemSettingsContent, }); }); }); it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => { const mockHomeDir = path.resolve('/mock/home/user'); const mockSymlinkDir = path.resolve('/mock/symlink/to/home'); const mockWorkspaceSettingsPath = path.join( mockSymlinkDir, GEMINI_DIR, 'settings.json', ); vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir); vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { const pStr = p.toString(); const resolved = path.resolve(pStr); if ( resolved === path.resolve(mockSymlinkDir) || resolved === path.resolve(mockHomeDir) ) { return mockHomeDir; } return pStr; }); // Force the storage check to return true for this specific test const isWorkspaceHomeDirSpy = vi .spyOn(Storage.prototype, 'isWorkspaceHomeDir') .mockReturnValue(true); (mockFsExistsSync as Mock).mockImplementation( (p: string) => // Only return true for workspace settings path to see if it gets loaded p === mockWorkspaceSettingsPath, ); try { const settings = loadSettings(mockSymlinkDir); // Verify that even though the file exists, it was NOT loaded because realpath matched home expect(fs.readFileSync).not.toHaveBeenCalledWith( mockWorkspaceSettingsPath, 'utf-8', ); expect(settings.workspace.settings).toEqual({}); } finally { isWorkspaceHomeDirSpy.mockRestore(); } }); describe('caching', () => { it('should cache loadSettings results', () => { const mockedRead = vi.mocked(fs.readFileSync); mockedRead.mockClear(); mockedRead.mockReturnValue('{}'); (mockFsExistsSync as Mock).mockReturnValue(true); const settings1 = loadSettings(MOCK_WORKSPACE_DIR); const settings2 = loadSettings(MOCK_WORKSPACE_DIR); expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file expect(settings1).toBe(settings2); }); it('should use separate cache for different workspace directories', () => { const mockedRead = vi.mocked(fs.readFileSync); mockedRead.mockClear(); mockedRead.mockReturnValue('{}'); (mockFsExistsSync as Mock).mockReturnValue(true); const workspace1 = path.resolve('/mock/workspace1'); const workspace2 = path.resolve('/mock/workspace2'); const settings1 = loadSettings(workspace1); const settings2 = loadSettings(workspace2); expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace expect(settings1).not.toBe(settings2); }); it('should clear cache when saveSettings is called for user settings', () => { const mockedRead = vi.mocked(fs.readFileSync); mockedRead.mockClear(); mockedRead.mockReturnValue('{}'); (mockFsExistsSync as Mock).mockReturnValue(true); const settings1 = loadSettings(MOCK_WORKSPACE_DIR); expect(mockedRead).toHaveBeenCalledTimes(5); saveSettings(settings1.user); const settings2 = loadSettings(MOCK_WORKSPACE_DIR); expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk expect(settings1).not.toBe(settings2); }); it('should clear all caches when saveSettings is called for workspace settings', () => { const mockedRead = vi.mocked(fs.readFileSync); mockedRead.mockClear(); mockedRead.mockReturnValue('{}'); (mockFsExistsSync as Mock).mockReturnValue(true); const workspace1 = path.resolve('/mock/workspace1'); const workspace2 = path.resolve('/mock/workspace2'); const settings1W1 = loadSettings(workspace1); const settings1W2 = loadSettings(workspace2); expect(mockedRead).toHaveBeenCalledTimes(10); // Save settings for workspace 1 saveSettings(settings1W1.workspace); const settings2W1 = loadSettings(workspace1); const settings2W2 = loadSettings(workspace2); // Both workspace caches should have been cleared and re-read from disk (+10 reads) expect(mockedRead).toHaveBeenCalledTimes(20); expect(settings1W1).not.toBe(settings2W1); expect(settings1W2).not.toBe(settings2W2); }); }); }); describe('excludedProjectEnvVars integration', () => { const originalEnv = { ...process.env }; beforeEach(() => { process.env = { ...originalEnv }; }); afterEach(() => { process.env = originalEnv; }); it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { // Create a workspace settings file with excludedProjectEnvVars const workspaceSettingsContent = { general: {}, advanced: { excludedEnvVars: ['DEBUG', 'DEBUG_MODE'] }, }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); // Mock findEnvFile to return a project .env file const originalFindEnvFile = ( loadSettings as unknown as { findEnvFile: () => string } ).findEnvFile; (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = () => path.resolve('/mock/project/.env'); // Mock fs.readFileSync for .env file content const originalReadFileSync = fs.readFileSync; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (p === path.resolve('/mock/project/.env')) { return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; } if ( normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; }, ); try { // This will call loadEnvironment internally with the merged settings const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify the settings were loaded correctly expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'DEBUG_MODE', ]); // Note: We can't directly test process.env changes here because the mocking // prevents the actual file system operations, but we can verify the settings // are correctly merged and passed to loadEnvironment } finally { (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = originalFindEnvFile; (fs.readFileSync as Mock).mockImplementation(originalReadFileSync); } }); it('should respect custom excludedProjectEnvVars from user settings', () => { const userSettingsContent = { general: {}, advanced: { excludedEnvVars: ['NODE_ENV', 'DEBUG'] }, }; (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'NODE_ENV', 'DEBUG', ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'DEBUG_MODE', 'NODE_ENV', ]); }); it('should merge excludedProjectEnvVars with workspace taking precedence', () => { const userSettingsContent = { general: {}, advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, }; const workspaceSettingsContent = { general: {}, advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] }, }; (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.user.settings.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', 'USER_VAR', ]); expect(settings.workspace.settings.advanced?.excludedEnvVars).toEqual([ 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', 'DEBUG_MODE', 'NODE_ENV', 'USER_VAR', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR', ]); }); }); describe('with workspace trust', () => { it('should merge workspace settings when workspace is trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { ui: { theme: 'dark' }, tools: { sandbox: false }, }; const workspaceSettingsContent = { tools: { sandbox: true }, context: { fileName: 'WORKSPACE.md' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.tools?.sandbox).toBe(true); expect(settings.merged.context?.fileName).toBe('WORKSPACE.md'); expect(settings.merged.ui?.theme).toBe('dark'); }); it('should NOT merge workspace settings when workspace is not trusted', () => { vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: false, source: 'file', }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { ui: { theme: 'dark' }, tools: { sandbox: false }, context: { fileName: 'USER.md' }, }; const workspaceSettingsContent = { tools: { sandbox: true }, context: { fileName: 'WORKSPACE.md' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.tools?.sandbox).toBe(false); // User setting expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting expect(settings.merged.ui?.theme).toBe('dark'); // User setting }); it('should NOT merge workspace settings when workspace trust is undefined', () => { vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: undefined, source: undefined, }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { ui: { theme: 'dark' }, tools: { sandbox: false }, context: { fileName: 'USER.md' }, }; const workspaceSettingsContent = { tools: { sandbox: true }, context: { fileName: 'WORKSPACE.md' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.tools?.sandbox).toBe(false); // User setting expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting }); }); describe('loadEnvironment', () => { function setup({ isFolderTrustEnabled = true, isWorkspaceTrustedValue = true as boolean | undefined, }) { delete process.env['GEMINI_API_KEY']; // reset delete process.env['TESTTEST']; // reset const geminiEnvPath = path.resolve( path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'), ); vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: isWorkspaceTrustedValue, source: 'file', }); (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { const normalizedP = path.resolve(p.toString()); return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes( normalizedP, ); }); const userSettingsContent: Settings = { ui: { theme: 'dark', }, security: { folderTrust: { enabled: isFolderTrustEnabled, }, }, context: { fileName: 'USER_CONTEXT.md', }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { const normalizedP = path.resolve(p.toString()); if (normalizedP === path.resolve(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizedP === geminiEnvPath) return 'TESTTEST=1234\nGEMINI_API_KEY=test-key'; return '{}'; }, ); } it('sets environment variables from .env files', () => { setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true }); const settings = { security: { folderTrust: { enabled: false } }, } as Settings; loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); expect(process.env['TESTTEST']).toEqual('1234'); expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); }); it('does not load env files from untrusted spaces when sandboxed', () => { setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); const settings = { security: { folderTrust: { enabled: true } }, tools: { sandbox: true }, } as Settings; loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); expect(process.env['TESTTEST']).not.toEqual('1234'); }); it('does load env files from untrusted spaces when NOT sandboxed', () => { setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); const settings = { security: { folderTrust: { enabled: true } }, tools: { sandbox: false }, } as Settings; loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); expect(process.env['TESTTEST']).toEqual('1234'); }); it('does not load env files when trust is undefined and sandboxed', () => { delete process.env['TESTTEST']; // isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules. setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined }); const settings = { security: { folderTrust: { enabled: true } }, tools: { sandbox: true }, } as Settings; const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined }); loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn); expect(process.env['TESTTEST']).not.toEqual('1234'); expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); }); it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => { setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); const settings = createTestMergedSettings({ tools: { sandbox: true }, }); loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); // GEMINI_API_KEY is in the whitelist, so it should be loaded. expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); // TESTTEST is NOT in the whitelist, so it should be blocked. expect(process.env['TESTTEST']).not.toEqual('1234'); }); it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => { const originalArgv = [...process.argv]; process.argv.push('-s'); try { setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); const settings = createTestMergedSettings({ tools: { sandbox: false }, }); loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted); expect(process.env['GEMINI_API_KEY']).toEqual('test-key'); expect(process.env['TESTTEST']).not.toEqual('1234'); } finally { process.argv = originalArgv; } }); }); describe('migrateDeprecatedSettings', () => { let mockFsExistsSync: Mock; let mockFsReadFileSync: Mock; beforeEach(() => { vi.resetAllMocks(); mockFsExistsSync = vi.mocked(fs.existsSync); mockFsExistsSync.mockReturnValue(true); mockFsReadFileSync = vi.mocked(fs.readFileSync); mockFsReadFileSync.mockReturnValue('{}'); vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: true, source: undefined, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('should not do anything if there are no deprecated settings', () => { const userSettingsContent = { extensions: { enabled: ['user-ext-1'], }, }; const workspaceSettingsContent = { someOtherSetting: 'value', }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, ); const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); setValueSpy.mockClear(); migrateDeprecatedSettings(loadedSettings, true); expect(setValueSpy).not.toHaveBeenCalled(); }); it('should migrate general.disableAutoUpdate to general.enableAutoUpdate with inverted value', () => { const userSettingsContent = { general: { disableAutoUpdate: true, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); migrateDeprecatedSettings(loadedSettings, true); // Should set new value to false (inverted from true) expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'general', expect.objectContaining({ enableAutoUpdate: false }), ); }); it('should migrate tools.approvalMode to general.defaultApprovalMode', () => { const userSettingsContent = { tools: { approvalMode: 'plan', }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); migrateDeprecatedSettings(loadedSettings, true); expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'general', expect.objectContaining({ defaultApprovalMode: 'plan' }), ); // Verify removal expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'tools', expect.not.objectContaining({ approvalMode: 'plan' }), ); }); it('should migrate all 4 inverted boolean settings', () => { const userSettingsContent = { general: { disableAutoUpdate: false, disableUpdateNag: true, }, context: { fileFiltering: { disableFuzzySearch: false, }, }, ui: { accessibility: { disableLoadingPhrases: true, }, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); migrateDeprecatedSettings(loadedSettings, true); // Check that general settings were migrated with inverted values expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'general', expect.objectContaining({ enableAutoUpdate: true }), ); expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'general', expect.objectContaining({ enableAutoUpdateNotification: false }), ); // Check context.fileFiltering was migrated expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'context', expect.objectContaining({ fileFiltering: expect.objectContaining({ enableFuzzySearch: true }), }), ); // Check ui.accessibility was migrated expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'ui', expect.objectContaining({ accessibility: expect.objectContaining({ enableLoadingPhrases: false, }), }), ); // Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off' expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'ui', expect.objectContaining({ loadingPhrases: 'off', }), ); }); it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => { const userSettingsContent = { ui: { accessibility: { enableLoadingPhrases: false, }, }, }; const loadedSettings = createMockSettings(userSettingsContent); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); migrateDeprecatedSettings(loadedSettings); expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'ui', expect.objectContaining({ loadingPhrases: 'off', }), ); }); it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => { const userSettingsContent = { ui: { accessibility: { enableLoadingPhrases: true, }, }, }; const loadedSettings = createMockSettings(userSettingsContent); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); migrateDeprecatedSettings(loadedSettings); // Should not set loadingPhrases when enableLoadingPhrases is true const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); for (const call of uiCalls) { const uiValue = call[2] as Record; expect(uiValue).not.toHaveProperty('loadingPhrases'); } }); it('should not overwrite existing loadingPhrases during migration', () => { const userSettingsContent = { ui: { loadingPhrases: 'witty', accessibility: { enableLoadingPhrases: false, }, }, }; const loadedSettings = createMockSettings(userSettingsContent); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); migrateDeprecatedSettings(loadedSettings); // Should not overwrite existing loadingPhrases const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui'); for (const call of uiCalls) { const uiValue = call[2] as Record; if (uiValue['loadingPhrases'] !== undefined) { expect(uiValue['loadingPhrases']).toBe('witty'); } } }); it('should remove deprecated settings by default and prioritize new ones', () => { const userSettingsContent = { general: { disableAutoUpdate: true, enableAutoUpdate: true, // Trust this (true) over disableAutoUpdate (true -> false) }, context: { fileFiltering: { disableFuzzySearch: false, enableFuzzySearch: false, // Trust this (false) over disableFuzzySearch (false -> true) }, }, }; const loadedSettings = createMockSettings(userSettingsContent); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); // Default is now removeDeprecated = true migrateDeprecatedSettings(loadedSettings); // Should remove disableAutoUpdate and trust enableAutoUpdate: true expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'general', { enableAutoUpdate: true, }); // Should remove disableFuzzySearch and trust enableFuzzySearch: false expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'context', { fileFiltering: { enableFuzzySearch: false }, }); }); it('should preserve deprecated settings when removeDeprecated is explicitly false', () => { const userSettingsContent = { general: { disableAutoUpdate: true, enableAutoUpdate: true, }, context: { fileFiltering: { disableFuzzySearch: false, enableFuzzySearch: false, }, }, }; const loadedSettings = createMockSettings(userSettingsContent); migrateDeprecatedSettings(loadedSettings, false); // Should still have old settings since removeDeprecated = false expect( loadedSettings.forScope(SettingScope.User).settings.general, ).toHaveProperty('disableAutoUpdate'); expect( ( loadedSettings.forScope(SettingScope.User).settings.context as { fileFiltering: { disableFuzzySearch: boolean }; } ).fileFiltering, ).toHaveProperty('disableFuzzySearch'); }); it('should trigger migration automatically during loadSettings', () => { mockFsExistsSync.mockImplementation( (p: fs.PathLike) => normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { general: { disableAutoUpdate: true, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify it was migrated in the merged settings expect(settings.merged.general?.enableAutoUpdate).toBe(false); // Verify it was saved back to disk (via setValue calling updateSettingsFilePreservingFormat) expect(updateSettingsFilePreservingFormat).toHaveBeenCalledWith( USER_SETTINGS_PATH, expect.objectContaining({ general: expect.objectContaining({ enableAutoUpdate: false }), }), ); }); it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => { const systemSettingsContent = { general: { disableUpdateNag: true, }, }; const systemDefaultsContent = { general: { disableUpdateNag: false, }, }; vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) { return JSON.stringify(systemDefaultsContent); } return '{}'; }, ); const feedbackSpy = mockCoreEvents.emitFeedback; const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify system settings were migrated in memory expect(settings.system.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); expect( (settings.system.settings.general as Record)[ 'enableAutoUpdateNotification' ], ).toBe(false); // Verify system defaults settings were migrated in memory expect(settings.systemDefaults.settings.general).toHaveProperty( 'enableAutoUpdateNotification', ); expect( (settings.systemDefaults.settings.general as Record)[ 'enableAutoUpdateNotification' ], ).toBe(true); // Merged should also reflect it (system overrides defaults, but both are migrated) expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false); // Verify it was NOT saved back to disk expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( getSystemSettingsPath(), expect.anything(), ); expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( getSystemDefaultsPath(), expect.anything(), ); // Verify warnings were shown expect(feedbackSpy).toHaveBeenCalledWith( 'warning', expect.stringContaining( 'The system configuration contains deprecated settings', ), ); expect(feedbackSpy).toHaveBeenCalledWith( 'warning', expect.stringContaining( 'The system default configuration contains deprecated settings', ), ); }); it('should migrate experimental agent settings in system scope in memory but not save', () => { const systemSettingsContent = { experimental: { codebaseInvestigatorSettings: { enabled: true, }, }, }; vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; }, ); const feedbackSpy = mockCoreEvents.emitFeedback; const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify it was migrated in memory expect(settings.system.settings.agents?.overrides).toMatchObject({ codebase_investigator: { enabled: true, }, }); // Verify it was NOT saved back to disk expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith( getSystemSettingsPath(), expect.anything(), ); // Verify warnings were shown expect(feedbackSpy).toHaveBeenCalledWith( 'warning', expect.stringContaining( 'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]', ), ); }); it('should migrate experimental agent settings to agents overrides', () => { const userSettingsContent = { experimental: { codebaseInvestigatorSettings: { enabled: true, maxNumTurns: 15, maxTimeMinutes: 5, thinkingBudget: 16384, model: 'gemini-1.5-pro', }, cliHelpAgentSettings: { enabled: false, }, }, }; vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); // Verify migration to agents.overrides expect(settings.user.settings.agents?.overrides).toMatchObject({ codebase_investigator: { enabled: true, runConfig: { maxTurns: 15, maxTimeMinutes: 5, }, modelConfig: { model: 'gemini-1.5-pro', generateContentConfig: { thinkingConfig: { thinkingBudget: 16384, }, }, }, }, cli_help: { enabled: false, }, }); }); }); describe('saveSettings', () => { it('should save settings using updateSettingsFilePreservingFormat', () => { const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user; settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); expect(mockUpdateSettings).toHaveBeenCalledWith( path.resolve('/mock/settings.json'), { ui: { theme: 'dark' }, }, ); }); it('should create directory if it does not exist', () => { const mockFsExistsSync = vi.mocked(fs.existsSync); const mockFsMkdirSync = vi.mocked(fs.mkdirSync); mockFsExistsSync.mockReturnValue(false); const settingsFile = createMockSettings({}).user; settingsFile.path = path.resolve('/mock/new/dir/settings.json'); saveSettings(settingsFile); expect(mockFsExistsSync).toHaveBeenCalledWith( path.resolve('/mock/new/dir'), ); expect(mockFsMkdirSync).toHaveBeenCalledWith( path.resolve('/mock/new/dir'), { recursive: true, }, ); }); it('should emit error feedback if saving fails', () => { const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); const error = new Error('Write failed'); mockUpdateSettings.mockImplementation(() => { throw error; }); const settingsFile = createMockSettings({}).user; settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'Failed to save settings: Write failed', error, ); }); }); describe('LoadedSettings and remote admin settings', () => { it('should prioritize remote admin settings over file-based admin settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { admin: { // These should be ignored secureModeEnabled: true, mcp: { enabled: false }, extensions: { enabled: false }, }, // A non-admin setting to ensure it's still processed ui: { theme: 'system-theme' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; }, ); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); // 1. Verify that on initial load, file-based admin settings are ignored // and schema defaults are used instead. expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); // default: false expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // default: true expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // default: true expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); // non-admin setting should be loaded // 2. Now, set remote admin settings. loadedSettings.setRemoteAdminSettings({ strictModeDisabled: false, mcpSetting: { mcpEnabled: false, mcpConfig: {} }, cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false }, unmanagedCapabilitiesEnabled: false, }, }); // 3. Verify that remote admin settings take precedence. expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // non-admin setting should remain unchanged expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); }); it('should set remote admin settings and recompute merged settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { admin: { secureModeEnabled: false, mcp: { enabled: false }, extensions: { enabled: false }, }, ui: { theme: 'initial-theme' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; }, ); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); // Ensure initial state from defaults (as file-based admin settings are ignored) expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); const newRemoteSettings = { strictModeDisabled: false, mcpSetting: { mcpEnabled: false, mcpConfig: {} }, cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false }, unmanagedCapabilitiesEnabled: false, }, }; loadedSettings.setRemoteAdminSettings(newRemoteSettings); // Verify that remote admin settings are applied expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Non-admin settings should remain untouched expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); }); it('should correctly handle undefined remote admin settings', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { ui: { theme: 'initial-theme' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; }, ); const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); // Should have default admin settings expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); loadedSettings.setRemoteAdminSettings({}); // Set empty remote settings // Admin settings should revert to defaults because there are no remote overrides expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); }); it('should un-nest MCP configuration from remote settings', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const mcpServers: Record = { 'admin-server': { url: 'http://admin-mcp.com', type: 'sse', trust: true, }, }; loadedSettings.setRemoteAdminSettings({ mcpSetting: { mcpEnabled: true, mcpConfig: { mcpServers, }, }, }); expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); }); it('should map requiredMcpConfig from remote settings', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const requiredMcpConfig = { 'corp-tool': { url: 'https://mcp.corp/tool', type: 'http' as const, trust: true, }, }; loadedSettings.setRemoteAdminSettings({ mcpSetting: { mcpEnabled: true, requiredMcpConfig, }, }); expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( requiredMcpConfig, ); }); it('should set skills based on unmanagedCapabilitiesEnabled', () => { const loadedSettings = loadSettings(); loadedSettings.setRemoteAdminSettings({ cliFeatureSetting: { unmanagedCapabilitiesEnabled: true, }, }); expect(loadedSettings.merged.admin.skills?.enabled).toBe(true); loadedSettings.setRemoteAdminSettings({ cliFeatureSetting: { unmanagedCapabilitiesEnabled: false, }, }); expect(loadedSettings.merged.admin.skills?.enabled).toBe(false); }); it('should handle completely empty remote admin settings response', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); loadedSettings.setRemoteAdminSettings({}); // Should default to schema defaults (standard defaults) expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); }); }); describe('getDefaultsFromSchema', () => { it('should extract defaults from a schema', () => { const mockSchema = { prop1: { type: 'string', default: 'default1', label: 'Prop 1', category: 'General', requiresRestart: false, }, nested: { type: 'object', label: 'Nested', category: 'General', requiresRestart: false, default: {}, properties: { prop2: { type: 'number', default: 42, label: 'Prop 2', category: 'General', requiresRestart: false, }, }, }, }; const defaults = getDefaultsFromSchema(mockSchema as SettingsSchema); expect(defaults).toEqual({ prop1: 'default1', nested: { prop2: 42, }, }); }); }); describe('Reactivity & Snapshots', () => { let loadedSettings: LoadedSettings; beforeEach(() => { const emptySettingsFile: SettingsFile = { path: path.resolve('/mock/path'), settings: {}, originalSettings: {}, }; loadedSettings = new LoadedSettings( { ...emptySettingsFile, path: getSystemSettingsPath() }, { ...emptySettingsFile, path: getSystemDefaultsPath() }, { ...emptySettingsFile, path: USER_SETTINGS_PATH }, { ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH }, true, // isTrusted [], ); }); it('getSnapshot() should return stable reference if no changes occur', () => { const snap1 = loadedSettings.getSnapshot(); const snap2 = loadedSettings.getSnapshot(); expect(snap1).toBe(snap2); }); it('setValue() should create a new snapshot reference and emit event', () => { const oldSnapshot = loadedSettings.getSnapshot(); const oldUserRef = oldSnapshot.user.settings; loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast'); const newSnapshot = loadedSettings.getSnapshot(); expect(newSnapshot).not.toBe(oldSnapshot); expect(newSnapshot.user.settings).not.toBe(oldUserRef); expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast'); expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings); expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled(); }); }); describe('Security and Sandbox', () => { let originalArgv: string[]; let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalArgv = [...process.argv]; originalEnv = { ...process.env }; // Clear relevant env vars delete process.env['GEMINI_API_KEY']; delete process.env['GOOGLE_API_KEY']; delete process.env['GOOGLE_CLOUD_PROJECT']; delete process.env['GOOGLE_CLOUD_LOCATION']; delete process.env['CLOUD_SHELL']; delete process.env['MALICIOUS_VAR']; delete process.env['FOO']; vi.resetAllMocks(); vi.mocked(fs.existsSync).mockReturnValue(false); }); afterEach(() => { process.argv = originalArgv; process.env = originalEnv; }); describe('sandbox detection', () => { it('should detect sandbox when -s is a real flag', () => { process.argv = ['node', 'gemini', '-s', 'some prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue( 'FOO=bar\nGEMINI_API_KEY=secret', ); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); // If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be. expect(process.env['FOO']).toBeUndefined(); expect(process.env['GEMINI_API_KEY']).toBe('secret'); }); it('should detect sandbox when --sandbox is a real flag', () => { process.argv = ['node', 'gemini', '--sandbox', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toBe('secret'); }); it('should ignore sandbox flags if they appear after --', () => { process.argv = ['node', 'gemini', '--', '-s', 'some prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toEqual('secret'); }); it('should NOT be tricked by positional arguments that look like flags', () => { process.argv = ['node', 'gemini', 'my -s prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret'); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toEqual('secret'); }); }); describe('env var sanitization', () => { it('should strictly enforce whitelist in untrusted/sandboxed mode', () => { process.argv = ['node', 'gemini', '-s', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); vi.mocked(fs.readFileSync).mockReturnValue(` GEMINI_API_KEY=secret-key MALICIOUS_VAR=should-be-ignored GOOGLE_API_KEY=another-secret `); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toBe('secret-key'); expect(process.env['GOOGLE_API_KEY']).toBe('another-secret'); expect(process.env['MALICIOUS_VAR']).toBeUndefined(); }); it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => { process.argv = ['node', 'gemini', '--sandbox', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}'; vi.mocked(fs.readFileSync).mockReturnValue( `GEMINI_API_KEY=${maliciousPayload}`, ); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); // sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '') expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-'); }); it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => { process.argv = ['node', 'gemini', '--sandbox', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); const complexPayload = 'secret-123/path.to/somewhere;rm -rf /'; vi.mocked(fs.readFileSync).mockReturnValue( `GEMINI_API_KEY=${complexPayload}`, ); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toBe( 'secret-123/path.to/somewhererm-rf/', ); }); it('should NOT sanitize variables from trusted sources', () => { process.argv = ['node', 'gemini', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)'); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); // Trusted source, no sanitization expect(process.env['FOO']).toBe('$(bar)'); }); it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => { process.argv = ['node', 'gemini', '-s', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); vi.mocked(fs.existsSync).mockImplementation((path) => path.toString().endsWith('.env'), ); vi.mocked(fs.readFileSync).mockReturnValue(` GEMINI_API_KEY=un-sanitized;key! MALICIOUS_VAR=allowed-because-trusted `); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!'); expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted'); }); it('should sanitize value in sanitizeEnvVar helper', () => { expect(sanitizeEnvVar('$(calc)')).toBe('calc'); expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/'); expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123'); expect(sanitizeEnvVar('us-central1')).toBe('us-central1'); }); }); describe('Cloud Shell security', () => { it('should handle Cloud Shell special defaults securely when untrusted', () => { process.env['CLOUD_SHELL'] = 'true'; process.argv = ['node', 'gemini', '-s', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); // No .env file vi.mocked(fs.existsSync).mockReturnValue(false); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca'); }); it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => { process.env['CLOUD_SHELL'] = 'true'; process.argv = ['node', 'gemini', '-s', 'prompt']; vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue( 'GOOGLE_CLOUD_PROJECT=attacker-project;inject', ); loadEnvironment( createMockSettings({ tools: { sandbox: false } }).merged, MOCK_WORKSPACE_DIR, ); expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe( 'attacker-projectinject', ); }); }); }); }); describe('LoadedSettings Isolation and Serializability', () => { let loadedSettings: LoadedSettings; interface TestData { a: { b: number; }; } beforeEach(() => { vi.resetAllMocks(); // Create a minimal LoadedSettings instance const emptyScope = { path: path.resolve('/mock/settings.json'), settings: {}, originalSettings: {}, } as unknown as SettingsFile; loadedSettings = new LoadedSettings( emptyScope, // system emptyScope, // systemDefaults { ...emptyScope }, // user emptyScope, // workspace true, // isTrusted ); }); describe('setValue Isolation', () => { it('should isolate state between settings and originalSettings', () => { const complexValue: TestData = { a: { b: 1 } }; loadedSettings.setValue(SettingScope.User, 'test', complexValue); const userSettings = loadedSettings.forScope(SettingScope.User); const settingsValue = (userSettings.settings as Record)[ 'test' ] as TestData; const originalValue = ( userSettings.originalSettings as Record )['test'] as TestData; // Verify they are equal but different references expect(settingsValue).toEqual(complexValue); expect(originalValue).toEqual(complexValue); expect(settingsValue).not.toBe(complexValue); expect(originalValue).not.toBe(complexValue); expect(settingsValue).not.toBe(originalValue); // Modify the in-memory setting object settingsValue.a.b = 2; // originalSettings should NOT be affected expect(originalValue.a.b).toBe(1); }); it('should not share references between settings and originalSettings (original servers test)', () => { const mcpServers = { 'test-server': { command: 'echo' }, }; loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers); // Modify the original object delete (mcpServers as Record)['test-server']; // The settings in LoadedSettings should still have the server const userSettings = loadedSettings.forScope(SettingScope.User); expect( (userSettings.settings.mcpServers as Record)[ 'test-server' ], ).toBeDefined(); expect( (userSettings.originalSettings.mcpServers as Record)[ 'test-server' ], ).toBeDefined(); // They should also be different objects from each other expect(userSettings.settings.mcpServers).not.toBe( userSettings.originalSettings.mcpServers, ); }); }); describe('setValue Serializability', () => { it('should preserve Map/Set types (via structuredClone)', () => { const mapValue = { myMap: new Map([['key', 'value']]) }; loadedSettings.setValue(SettingScope.User, 'test', mapValue); const userSettings = loadedSettings.forScope(SettingScope.User); const settingsValue = (userSettings.settings as Record)[ 'test' ] as { myMap: Map }; // Map is preserved by structuredClone expect(settingsValue.myMap).toBeInstanceOf(Map); expect(settingsValue.myMap.get('key')).toBe('value'); // But it should be a different reference expect(settingsValue.myMap).not.toBe(mapValue.myMap); }); it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => { const circular: Record = { a: 1 }; circular['self'] = circular; // structuredClone(circular) works, but LoadedSettings.setValue calls // computeMergedSettings() -> customDeepMerge() which blows up on circularity. expect(() => { loadedSettings.setValue(SettingScope.User, 'test', circular); }).toThrow(/Maximum call stack size exceeded/); }); }); }); ================================================ FILE: packages/cli/src/config/settings.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { CoreEvent, FatalConfigError, GEMINI_DIR, getErrorMessage, getFsErrorMessage, Storage, coreEvents, homedir, type AdminControlsSettings, createCache, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; export { type Settings, type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, }; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { validateSettings, formatValidationError, } from './settings-validation.js'; export function getMergeStrategyForPath( path: string[], ): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); let parent: SettingDefinition | undefined = undefined; for (const key of path) { if (!currentSchema || !currentSchema[key]) { // Key not found in schema - check if parent has additionalProperties if (parent?.additionalProperties?.mergeStrategy) { return parent.additionalProperties.mergeStrategy; } return undefined; } parent = current; current = currentSchema[key]; currentSchema = current.properties; } return current?.mergeStrategy; } export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; const AUTH_ENV_VAR_WHITELIST = [ 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', ]; /** * Sanitizes an environment variable value to prevent shell injection. * Restricts values to a safe character set: alphanumeric, -, _, ., / */ export function sanitizeEnvVar(value: string): string { return value.replace(/[^a-zA-Z0-9\-_./]/g, ''); } export function getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; } if (platform() === 'darwin') { return '/Library/Application Support/GeminiCli/settings.json'; } else if (platform() === 'win32') { return 'C:\\ProgramData\\gemini-cli\\settings.json'; } else { return '/etc/gemini-cli/settings.json'; } } export function getSystemDefaultsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']; } return path.join( path.dirname(getSystemSettingsPath()), 'system-defaults.json', ); } export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { User = 'User', Workspace = 'Workspace', System = 'System', SystemDefaults = 'SystemDefaults', // Note that this scope is not supported in the settings dialog at this time, // it is only supported for extensions. Session = 'Session', } /** * A type representing the settings scopes that are supported for LoadedSettings. */ export type LoadableSettingScope = | SettingScope.User | SettingScope.Workspace | SettingScope.System | SettingScope.SystemDefaults; /** * The actual values of the loadable settings scopes. */ const _loadableSettingScopes = [ SettingScope.User, SettingScope.Workspace, SettingScope.System, SettingScope.SystemDefaults, ]; /** * A type guard function that checks if `scope` is a loadable settings scope, * and allows promotion to the `LoadableSettingsScope` type based on the result. */ export function isLoadableSettingScope( scope: SettingScope, ): scope is LoadableSettingScope { return _loadableSettingScopes.includes(scope); } export interface CheckpointingSettings { enabled?: boolean; } export interface SummarizeToolOutputSettings { tokenBudget?: number; } export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off'; export interface AccessibilitySettings { /** @deprecated Use ui.loadingPhrases instead. */ enableLoadingPhrases?: boolean; screenReader?: boolean; } export interface SessionRetentionSettings { /** Enable automatic session cleanup */ enabled?: boolean; /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ maxAge?: string; /** Alternative: Maximum number of sessions to keep (most recent) */ maxCount?: number; /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; } export interface SettingsError { message: string; path: string; severity: 'error' | 'warning'; } export interface SettingsFile { settings: Settings; originalSettings: Settings; path: string; rawJson?: string; readOnly?: boolean; } function setNestedProperty( obj: Record, path: string, value: unknown, ) { const keys = path.split('.'); const lastKey = keys.pop(); if (!lastKey) return; let current: Record = obj; for (const key of keys) { if (current[key] === undefined) { current[key] = {}; } const next = current[key]; if (typeof next === 'object' && next !== null) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion current = next as Record; } else { // This path is invalid, so we stop. return; } } current[lastKey] = value; } export function getDefaultsFromSchema( schema: SettingsSchema = getSettingsSchema(), ): Settings { const defaults: Record = {}; for (const key in schema) { const definition = schema[key]; if (definition.properties) { defaults[key] = getDefaultsFromSchema(definition.properties); } else if (definition.default !== undefined) { defaults[key] = definition.default; } } return defaults as Settings; } export function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, ): MergedSettings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); const schemaDefaults = getDefaultsFromSchema(); // Settings are merged with the following precedence (last one wins for // single values): // 1. Schema Defaults (Built-in) // 2. System Defaults // 3. User Settings // 4. Workspace Settings // 5. System Settings (as overrides) // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, schemaDefaults, systemDefaults, user, safeWorkspace, system, ) as MergedSettings; } /** * Creates a fully populated MergedSettings object for testing purposes. * It merges the provided overrides with the default settings from the schema. * * @param overrides Partial settings to override the defaults. * @returns A complete MergedSettings object. */ export function createTestMergedSettings( overrides: Partial = {}, ): MergedSettings { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return customDeepMerge( getMergeStrategyForPath, getDefaultsFromSchema(), overrides, ) as MergedSettings; } /** * An immutable snapshot of settings state. * Used with useSyncExternalStore for reactive updates. */ export interface LoadedSettingsSnapshot { system: SettingsFile; systemDefaults: SettingsFile; user: SettingsFile; workspace: SettingsFile; isTrusted: boolean; errors: SettingsError[]; merged: MergedSettings; } export class LoadedSettings { constructor( system: SettingsFile, systemDefaults: SettingsFile, user: SettingsFile, workspace: SettingsFile, isTrusted: boolean, errors: SettingsError[] = [], ) { this.system = system; this.systemDefaults = systemDefaults; this.user = user; this._workspaceFile = workspace; this.isTrusted = isTrusted; this.workspace = isTrusted ? workspace : this.createEmptyWorkspace(workspace); this.errors = errors; this._merged = this.computeMergedSettings(); this._snapshot = this.computeSnapshot(); } readonly system: SettingsFile; readonly systemDefaults: SettingsFile; readonly user: SettingsFile; workspace: SettingsFile; isTrusted: boolean; readonly errors: SettingsError[]; private _workspaceFile: SettingsFile; private _merged: MergedSettings; private _snapshot: LoadedSettingsSnapshot; private _remoteAdminSettings: Partial | undefined; get merged(): MergedSettings { return this._merged; } setTrusted(isTrusted: boolean): void { if (this.isTrusted === isTrusted) { return; } this.isTrusted = isTrusted; this.workspace = isTrusted ? this._workspaceFile : this.createEmptyWorkspace(this._workspaceFile); this._merged = this.computeMergedSettings(); coreEvents.emitSettingsChanged(); } private createEmptyWorkspace(workspace: SettingsFile): SettingsFile { return { ...workspace, settings: {}, originalSettings: {}, }; } private computeMergedSettings(): MergedSettings { const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); // Remote admin settings always take precedence and file-based admin settings // are ignored. const adminSettingSchema = getSettingsSchema().admin; if (adminSettingSchema?.properties) { const adminSchema = adminSettingSchema.properties; const adminDefaults = getDefaultsFromSchema(adminSchema); // The final admin settings are the defaults overridden by remote settings. // Any admin settings from files are ignored. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion merged.admin = customDeepMerge( (path: string[]) => getMergeStrategyForPath(['admin', ...path]), adminDefaults, this._remoteAdminSettings?.admin ?? {}, ) as MergedSettings['admin']; } return merged; } private computeSnapshot(): LoadedSettingsSnapshot { const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({ path: file.path, rawJson: file.rawJson, settings: structuredClone(file.settings), originalSettings: structuredClone(file.originalSettings), }); return { system: cloneSettingsFile(this.system), systemDefaults: cloneSettingsFile(this.systemDefaults), user: cloneSettingsFile(this.user), workspace: cloneSettingsFile(this.workspace), isTrusted: this.isTrusted, errors: [...this.errors], merged: structuredClone(this._merged), }; } // Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes // React will pass a listener fn into this subscribe fn // that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed subscribe(listener: () => void): () => void { coreEvents.on(CoreEvent.SettingsChanged, listener); return () => coreEvents.off(CoreEvent.SettingsChanged, listener); } getSnapshot(): LoadedSettingsSnapshot { return this._snapshot; } forScope(scope: LoadableSettingScope): SettingsFile { switch (scope) { case SettingScope.User: return this.user; case SettingScope.Workspace: return this.workspace; case SettingScope.System: return this.system; case SettingScope.SystemDefaults: return this.systemDefaults; default: throw new Error(`Invalid scope: ${scope}`); } } private isPersistable(settingsFile: SettingsFile): boolean { return !settingsFile.readOnly; } setValue(scope: LoadableSettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); // Clone value to prevent reference sharing const valueToSet = typeof value === 'object' && value !== null ? structuredClone(value) : value; setNestedProperty(settingsFile.settings, key, valueToSet); if (this.isPersistable(settingsFile)) { // Use a fresh clone for originalSettings to ensure total independence setNestedProperty( settingsFile.originalSettings, key, structuredClone(valueToSet), ); saveSettings(settingsFile); } this._merged = this.computeMergedSettings(); this._snapshot = this.computeSnapshot(); coreEvents.emitSettingsChanged(); } setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void { const admin: Settings['admin'] = {}; const { strictModeDisabled, mcpSetting, cliFeatureSetting } = remoteSettings; if (Object.keys(remoteSettings).length === 0) { this._remoteAdminSettings = { admin }; this._merged = this.computeMergedSettings(); return; } admin.secureModeEnabled = !strictModeDisabled; admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, requiredConfig: mcpSetting?.requiredMcpConfig, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, }; admin.skills = { enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled, }; this._remoteAdminSettings = { admin }; this._merged = this.computeMergedSettings(); } } function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); if (fs.existsSync(geminiEnvPath)) { return geminiEnvPath; } const envPath = path.join(currentDir, '.env'); if (fs.existsSync(envPath)) { return envPath; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { // check .env under home as fallback, again preferring gemini-specific .env const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); if (fs.existsSync(homeGeminiEnvPath)) { return homeGeminiEnvPath; } const homeEnvPath = path.join(homedir(), '.env'); if (fs.existsSync(homeEnvPath)) { return homeEnvPath; } return null; } currentDir = parentDir; } } export function setUpCloudShellEnvironment( envFilePath: string | null, isTrusted: boolean, isSandboxed: boolean, ): void { // Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell: // Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project // set by the user using "gcloud config set project" we do not want to // use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in // one of the .env files, we set the Cloud Shell-specific default here. let value = 'cloudshell-gca'; if (envFilePath && fs.existsSync(envFilePath)) { const envFileContent = fs.readFileSync(envFilePath); const parsedEnv = dotenv.parse(envFileContent); if (parsedEnv['GOOGLE_CLOUD_PROJECT']) { // .env file takes precedence in Cloud Shell value = parsedEnv['GOOGLE_CLOUD_PROJECT']; if (!isTrusted && isSandboxed) { value = sanitizeEnvVar(value); } } } process.env['GOOGLE_CLOUD_PROJECT'] = value; } export function loadEnvironment( settings: Settings, workspaceDir: string, isWorkspaceTrustedFn = isWorkspaceTrusted, ): void { const envFilePath = findEnvFile(workspaceDir); const trustResult = isWorkspaceTrustedFn(settings, workspaceDir); const isTrusted = trustResult.isTrusted ?? false; // Check settings OR check process.argv directly since this might be called // before arguments are fully parsed. This is a best-effort sniffing approach // that happens early in the CLI lifecycle. It is designed to detect the // sandbox flag before the full command-line parser is initialized to ensure // security constraints are applied when loading environment variables. const args = process.argv.slice(2); const doubleDashIndex = args.indexOf('--'); const relevantArgs = doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex); const isSandboxed = !!settings.tools?.sandbox || relevantArgs.includes('-s') || relevantArgs.includes('--sandbox'); // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed); } if (envFilePath) { // Manually parse and load environment variables to handle exclusions correctly. // This avoids modifying environment variables that were already set from the shell. try { const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); const parsedEnv = dotenv.parse(envFileContent); const excludedVars = settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { let value = parsedEnv[key]; // If the workspace is untrusted but we are sandboxed, only allow whitelisted variables. if (!isTrusted && isSandboxed) { if (!AUTH_ENV_VAR_WHITELIST.includes(key)) { continue; } // Sanitize the value for untrusted sources value = sanitizeEnvVar(value); } // If it's a project .env file, skip loading excluded variables. if (isProjectEnvFile && excludedVars.includes(key)) { continue; } // Load variable only if it's not already set in the environment. if (!Object.hasOwn(process.env, key)) { process.env[key] = value; } } } } catch (_e) { // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. } } } // Cache to store the results of loadSettings to avoid redundant disk I/O. const settingsCache = createCache({ storage: 'map', defaultTtl: 10000, // 10 seconds }); /** * Resets the settings cache. Used exclusively for test isolation. * @internal */ export function resetSettingsCacheForTesting() { settingsCache.clear(); } /** * Loads settings from user and workspace directories. * Project settings override user settings. */ export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { const normalizedWorkspaceDir = path.resolve(workspaceDir); return settingsCache.getOrCreate(normalizedWorkspaceDir, () => _doLoadSettings(normalizedWorkspaceDir), ); } /** * Internal implementation of the settings loading logic. */ function _doLoadSettings(workspaceDir: string): LoadedSettings { let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); const storage = new Storage(workspaceDir); const workspaceSettingsPath = storage.getWorkspaceSettingsPath(); const load = (filePath: string): { settings: Settings; rawJson?: string } => { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); const rawSettings: unknown = JSON.parse(stripJsonComments(content)); if ( typeof rawSettings !== 'object' || rawSettings === null || Array.isArray(rawSettings) ) { settingsErrors.push({ message: 'Settings file is not a valid JSON object.', path: filePath, severity: 'error', }); return { settings: {} }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const settingsObject = rawSettings as Record; // Validate settings structure with Zod const validationResult = validateSettings(settingsObject); if (!validationResult.success && validationResult.error) { const errorMessage = formatValidationError( validationResult.error, filePath, ); settingsErrors.push({ message: errorMessage, path: filePath, severity: 'warning', }); } return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: filePath, severity: 'error', }); } return { settings: {} }; }; const systemResult = load(systemSettingsPath); const systemDefaultsResult = load(systemDefaultsPath); const userResult = load(USER_SETTINGS_PATH); let workspaceResult: { settings: Settings; rawJson?: string } = { settings: {} as Settings, rawJson: undefined, }; if (!storage.isWorkspaceHomeDir()) { workspaceResult = load(workspaceSettingsPath); } const systemOriginalSettings = structuredClone(systemResult.settings); const systemDefaultsOriginalSettings = structuredClone( systemDefaultsResult.settings, ); const userOriginalSettings = structuredClone(userResult.settings); const workspaceOriginalSettings = structuredClone(workspaceResult.settings); // Environment variables for runtime use systemSettings = resolveEnvVarsInObject(systemResult.settings); systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings); userSettings = resolveEnvVarsInObject(userResult.settings); workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); // Support legacy theme names if (userSettings.ui?.theme === 'VS') { userSettings.ui.theme = DefaultLight.name; } else if (userSettings.ui?.theme === 'VS2015') { userSettings.ui.theme = DefaultDark.name; } if (workspaceSettings.ui?.theme === 'VS') { workspaceSettings.ui.theme = DefaultLight.name; } else if (workspaceSettings.ui?.theme === 'VS2015') { workspaceSettings.ui.theme = DefaultDark.name; } // For the initial trust check, we can only use user and system settings. const initialTrustCheckSettings = customDeepMerge( getMergeStrategyForPath, getDefaultsFromSchema(), systemDefaultSettings, userSettings, systemSettings, ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir) .isTrusted ?? false; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( systemSettings, systemDefaultSettings, userSettings, workspaceSettings, isTrusted, ); // loadEnvironment depends on settings so we have to create a temp version of // the settings to avoid a cycle loadEnvironment(tempMergedSettings, workspaceDir); // Check for any fatal errors before proceeding const fatalErrors = settingsErrors.filter((e) => e.severity === 'error'); if (fatalErrors.length > 0) { const errorMessages = fatalErrors.map( (error) => `Error in ${error.path}: ${error.message}`, ); throw new FatalConfigError( `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, ); } const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, originalSettings: systemOriginalSettings, rawJson: systemResult.rawJson, readOnly: true, }, { path: systemDefaultsPath, settings: systemDefaultSettings, originalSettings: systemDefaultsOriginalSettings, rawJson: systemDefaultsResult.rawJson, readOnly: true, }, { path: USER_SETTINGS_PATH, settings: userSettings, originalSettings: userOriginalSettings, rawJson: userResult.rawJson, readOnly: false, }, { path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath, settings: workspaceSettings, originalSettings: workspaceOriginalSettings, rawJson: workspaceResult.rawJson, readOnly: storage.isWorkspaceHomeDir(), }, isTrusted, settingsErrors, ); // Automatically migrate deprecated settings when loading. migrateDeprecatedSettings(loadedSettings); return loadedSettings; } /** * Migrates deprecated settings to their new counterparts. * * Deprecated settings are removed from settings files by default. * * @returns true if any changes were made and need to be saved. */ export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, removeDeprecated = true, ): boolean { let anyModified = false; const systemWarnings: Map = new Map(); /** * Helper to migrate a boolean setting and track it if it's deprecated. */ const migrateBoolean = ( settings: Record, oldKey: string, newKey: string, prefix: string, foundDeprecated?: string[], ): boolean => { let modified = false; const oldValue = settings[oldKey]; const newValue = settings[newKey]; if (typeof oldValue === 'boolean') { if (foundDeprecated) { foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey); } if (typeof newValue === 'boolean') { // Both exist, trust the new one if (removeDeprecated) { delete settings[oldKey]; modified = true; } } else { // Only old exists, migrate to new (inverted) settings[newKey] = !oldValue; if (removeDeprecated) { delete settings[oldKey]; } modified = true; } } return modified; }; const processScope = (scope: LoadableSettingScope) => { const settingsFile = loadedSettings.forScope(scope); const settings = settingsFile.settings; const foundDeprecated: string[] = []; // Migrate general settings const generalSettings = settings.general as | Record | undefined; if (generalSettings) { const newGeneral = { ...generalSettings }; let modified = false; modified = migrateBoolean( newGeneral, 'disableAutoUpdate', 'enableAutoUpdate', 'general', foundDeprecated, ) || modified; modified = migrateBoolean( newGeneral, 'disableUpdateNag', 'enableAutoUpdateNotification', 'general', foundDeprecated, ) || modified; if (modified) { loadedSettings.setValue(scope, 'general', newGeneral); if (!settingsFile.readOnly) { anyModified = true; } } } // Migrate ui settings const uiSettings = settings.ui as Record | undefined; if (uiSettings) { const newUi = { ...uiSettings }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const accessibilitySettings = newUi['accessibility'] as | Record | undefined; if (accessibilitySettings) { const newAccessibility = { ...accessibilitySettings }; if ( migrateBoolean( newAccessibility, 'disableLoadingPhrases', 'enableLoadingPhrases', 'ui.accessibility', foundDeprecated, ) ) { newUi['accessibility'] = newAccessibility; loadedSettings.setValue(scope, 'ui', newUi); if (!settingsFile.readOnly) { anyModified = true; } } // Migrate enableLoadingPhrases: false → loadingPhrases: 'off' const enableLP = newAccessibility['enableLoadingPhrases']; if ( typeof enableLP === 'boolean' && newUi['loadingPhrases'] === undefined ) { if (!enableLP) { newUi['loadingPhrases'] = 'off'; loadedSettings.setValue(scope, 'ui', newUi); if (!settingsFile.readOnly) { anyModified = true; } } foundDeprecated.push('ui.accessibility.enableLoadingPhrases'); } } } // Migrate context settings const contextSettings = settings.context as | Record | undefined; if (contextSettings) { const newContext = { ...contextSettings }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const fileFilteringSettings = newContext['fileFiltering'] as | Record | undefined; if (fileFilteringSettings) { const newFileFiltering = { ...fileFilteringSettings }; if ( migrateBoolean( newFileFiltering, 'disableFuzzySearch', 'enableFuzzySearch', 'context.fileFiltering', foundDeprecated, ) ) { newContext['fileFiltering'] = newFileFiltering; loadedSettings.setValue(scope, 'context', newContext); if (!settingsFile.readOnly) { anyModified = true; } } } } // Migrate tools settings const toolsSettings = settings.tools as Record | undefined; if (toolsSettings) { if (toolsSettings['approvalMode'] !== undefined) { foundDeprecated.push('tools.approvalMode'); const generalSettings = (settings.general as Record | undefined) || {}; const newGeneral = { ...generalSettings }; // Only set defaultApprovalMode if it's not already set if (newGeneral['defaultApprovalMode'] === undefined) { newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode']; loadedSettings.setValue(scope, 'general', newGeneral); if (!settingsFile.readOnly) { anyModified = true; } } if (removeDeprecated) { const newTools = { ...toolsSettings }; delete newTools['approvalMode']; loadedSettings.setValue(scope, 'tools', newTools); if (!settingsFile.readOnly) { anyModified = true; } } } } // Migrate experimental agent settings const experimentalModified = migrateExperimentalSettings( settings, loadedSettings, scope, removeDeprecated, foundDeprecated, ); if (experimentalModified) { if (!settingsFile.readOnly) { anyModified = true; } } if (settingsFile.readOnly && foundDeprecated.length > 0) { systemWarnings.set(scope, foundDeprecated); } }; processScope(SettingScope.User); processScope(SettingScope.Workspace); processScope(SettingScope.System); processScope(SettingScope.SystemDefaults); if (systemWarnings.size > 0) { for (const [scope, flags] of systemWarnings) { const scopeName = scope === SettingScope.SystemDefaults ? 'system default' : scope.toLowerCase(); coreEvents.emitFeedback( 'warning', `The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`, ); } } return anyModified; } export function saveSettings(settingsFile: SettingsFile): void { // Clear the entire cache on any save. settingsCache.clear(); try { // Ensure the directory exists const dirPath = path.dirname(settingsFile.path); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } const settingsToSave = settingsFile.originalSettings; // Use the format-preserving update function updateSettingsFilePreservingFormat( settingsFile.path, settingsToSave as Record, ); } catch (error) { const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', `Failed to save settings: ${detailedErrorMessage}`, error, ); } } export function saveModelChange( loadedSettings: LoadedSettings, model: string, ): void { try { loadedSettings.setValue(SettingScope.User, 'model.name', model); } catch (error) { const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', `Failed to save preferred model: ${detailedErrorMessage}`, error, ); } } function migrateExperimentalSettings( settings: Settings, loadedSettings: LoadedSettings, scope: LoadableSettingScope, removeDeprecated: boolean, foundDeprecated?: string[], ): boolean { const experimentalSettings = settings.experimental as | Record | undefined; if (experimentalSettings) { const agentsSettings = { ...(settings.agents as Record | undefined), }; const agentsOverrides = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...((agentsSettings['overrides'] as Record) || {}), }; let modified = false; const migrateExperimental = ( oldKey: string, migrateFn: (oldValue: Record) => void, ) => { const old = experimentalSettings[oldKey]; if (old) { foundDeprecated?.push(`experimental.${oldKey}`); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion migrateFn(old as Record); modified = true; } }; // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator migrateExperimental('codebaseInvestigatorSettings', (old) => { const override = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['codebase_investigator'] as | Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; const runConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['runConfig'] as Record | undefined), }; if (old['maxNumTurns'] !== undefined) runConfig['maxTurns'] = old['maxNumTurns']; if (old['maxTimeMinutes'] !== undefined) runConfig['maxTimeMinutes'] = old['maxTimeMinutes']; if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig; if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) { const modelConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(override['modelConfig'] as Record | undefined), }; if (old['model'] !== undefined) modelConfig['model'] = old['model']; if (old['thinkingBudget'] !== undefined) { const generateContentConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(modelConfig['generateContentConfig'] as | Record | undefined), }; const thinkingConfig = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(generateContentConfig['thinkingConfig'] as | Record | undefined), }; thinkingConfig['thinkingBudget'] = old['thinkingBudget']; generateContentConfig['thinkingConfig'] = thinkingConfig; modelConfig['generateContentConfig'] = generateContentConfig; } override['modelConfig'] = modelConfig; } agentsOverrides['codebase_investigator'] = override; }); // Migrate cliHelpAgentSettings -> agents.overrides.cli_help migrateExperimental('cliHelpAgentSettings', (old) => { const override = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ...(agentsOverrides['cli_help'] as Record | undefined), }; if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; agentsOverrides['cli_help'] = override; }); if (modified) { agentsSettings['overrides'] = agentsOverrides; loadedSettings.setValue(scope, 'agents', agentsSettings); if (removeDeprecated) { const newExperimental = { ...experimentalSettings }; delete newExperimental['codebaseInvestigatorSettings']; delete newExperimental['cliHelpAgentSettings']; loadedSettings.setValue(scope, 'experimental', newExperimental); } return true; } } return false; } ================================================ FILE: packages/cli/src/config/settingsSchema.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { getSettingsSchema, SETTINGS_SCHEMA_DEFINITIONS, type SettingCollectionDefinition, type SettingDefinition, type Settings, type SettingsSchema, } from './settingsSchema.js'; describe('SettingsSchema', () => { describe('getSettingsSchema', () => { it('should contain all expected top-level settings', () => { const expectedSettings: Array = [ 'mcpServers', 'general', 'ui', 'ide', 'privacy', 'telemetry', 'model', 'context', 'tools', 'mcp', 'security', 'advanced', ]; expectedSettings.forEach((setting) => { expect(getSettingsSchema()[setting]).toBeDefined(); }); }); it('should have correct structure for each setting', () => { Object.entries(getSettingsSchema()).forEach(([_key, definition]) => { expect(definition).toHaveProperty('type'); expect(definition).toHaveProperty('label'); expect(definition).toHaveProperty('category'); expect(definition).toHaveProperty('requiresRestart'); expect(definition).toHaveProperty('default'); expect(typeof definition.type).toBe('string'); expect(typeof definition.label).toBe('string'); expect(typeof definition.category).toBe('string'); expect(typeof definition.requiresRestart).toBe('boolean'); }); }); it('should have correct nested setting structure', () => { const nestedSettings: Array = [ 'general', 'ui', 'ide', 'privacy', 'model', 'context', 'tools', 'mcp', 'security', 'advanced', ]; nestedSettings.forEach((setting) => { const definition = getSettingsSchema()[setting] as SettingDefinition; expect(definition.type).toBe('object'); expect(definition.properties).toBeDefined(); expect(typeof definition.properties).toBe('object'); }); }); it('should have accessibility nested properties', () => { expect( getSettingsSchema().ui?.properties?.accessibility?.properties, ).toBeDefined(); expect( getSettingsSchema().ui?.properties?.accessibility.properties ?.enableLoadingPhrases.type, ).toBe('boolean'); }); it('should have loadingPhrases enum property', () => { const definition = getSettingsSchema().ui?.properties?.loadingPhrases; expect(definition).toBeDefined(); expect(definition?.type).toBe('enum'); expect(definition?.default).toBe('tips'); expect(definition?.options?.map((o) => o.value)).toEqual([ 'tips', 'witty', 'all', 'off', ]); }); it('should have errorVerbosity enum property', () => { const definition = getSettingsSchema().ui?.properties?.errorVerbosity; expect(definition).toBeDefined(); expect(definition?.type).toBe('enum'); expect(definition?.default).toBe('low'); expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']); }); it('should have checkpointing nested properties', () => { expect( getSettingsSchema().general?.properties?.checkpointing.properties ?.enabled, ).toBeDefined(); expect( getSettingsSchema().general?.properties?.checkpointing.properties ?.enabled.type, ).toBe('boolean'); }); it('should have plan nested properties', () => { expect( getSettingsSchema().general?.properties?.plan?.properties?.directory, ).toBeDefined(); expect( getSettingsSchema().general?.properties?.plan?.properties?.directory .type, ).toBe('string'); }); it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties ?.respectGitIgnore, ).toBeDefined(); expect( getSettingsSchema().context.properties.fileFiltering.properties ?.respectGeminiIgnore, ).toBeDefined(); expect( getSettingsSchema().context.properties.fileFiltering.properties ?.enableRecursiveFileSearch, ).toBeDefined(); expect( getSettingsSchema().context.properties.fileFiltering.properties ?.customIgnoreFilePaths, ).toBeDefined(); expect( getSettingsSchema().context.properties.fileFiltering.properties ?.customIgnoreFilePaths.type, ).toBe('array'); }); it('should have unique categories', () => { const categories = new Set(); // Collect categories from top-level settings Object.values(getSettingsSchema()).forEach((definition) => { categories.add(definition.category); // Also collect from nested properties const defWithProps = definition as typeof definition & { properties?: Record; }; if (defWithProps.properties) { Object.values(defWithProps.properties).forEach( (nestedDef: unknown) => { const nestedDefTyped = nestedDef as { category?: string }; if (nestedDefTyped.category) { categories.add(nestedDefTyped.category); } }, ); } }); expect(categories.size).toBeGreaterThan(0); expect(categories).toContain('General'); expect(categories).toContain('UI'); expect(categories).toContain('Advanced'); }); it('should have consistent default values for boolean settings', () => { const checkBooleanDefaults = (schema: SettingsSchema) => { Object.entries(schema).forEach(([, definition]) => { const def = definition; if (def.type === 'boolean') { // Boolean settings can have boolean or undefined defaults (for optional settings) expect(['boolean', 'undefined']).toContain(typeof def.default); } if (def.properties) { checkBooleanDefaults(def.properties); } }); }; checkBooleanDefaults(getSettingsSchema() as SettingsSchema); }); it('should have showInDialog property configured', () => { // Check that user-facing settings are marked for dialog display expect( getSettingsSchema().ui.properties.showMemoryUsage.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.footer.properties .hideContextPercentage.showInDialog, ).toBe(true); expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe( true, ); expect(getSettingsSchema().ide.properties.enabled.showInDialog).toBe( true, ); expect( getSettingsSchema().general.properties.enableAutoUpdate.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, ).toBe(true); expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe( true, ); expect( getSettingsSchema().ui.properties.showShortcutsHint.showInDialog, ).toBe(true); expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe( true, ); expect( getSettingsSchema().privacy.properties.usageStatisticsEnabled .showInDialog, ).toBe(false); // Check that advanced settings are hidden from dialog expect(getSettingsSchema().security.properties.auth.showInDialog).toBe( false, ); expect(getSettingsSchema().tools.properties.core.showInDialog).toBe( false, ); expect(getSettingsSchema().mcpServers.showInDialog).toBe(false); expect(getSettingsSchema().telemetry.showInDialog).toBe(false); // Check that some settings are appropriately hidden expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe( false, ); // Managed via theme editor expect( getSettingsSchema().general.properties.checkpointing.showInDialog, ).toBe(false); // Experimental feature expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe( false, ); // Changed to false expect( getSettingsSchema().context.properties.fileFiltering.showInDialog, ).toBe(false); // Changed to false expect( getSettingsSchema().general.properties.preferredEditor.showInDialog, ).toBe(false); // Changed to false expect( getSettingsSchema().advanced.properties.autoConfigureMemory .showInDialog, ).toBe(true); }); it('should infer Settings type correctly', () => { // This test ensures that the Settings type is properly inferred from the schema const settings: Settings = { ui: { theme: 'dark', }, context: { includeDirectories: ['/path/to/dir'], loadMemoryFromIncludeDirectories: true, }, }; // TypeScript should not complain about these properties expect(settings.ui?.theme).toBe('dark'); expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']); expect(settings.context?.loadMemoryFromIncludeDirectories).toBe(true); }); it('should have includeDirectories setting in schema', () => { expect( getSettingsSchema().context?.properties.includeDirectories, ).toBeDefined(); expect( getSettingsSchema().context?.properties.includeDirectories.type, ).toBe('array'); expect( getSettingsSchema().context?.properties.includeDirectories.category, ).toBe('Context'); expect( getSettingsSchema().context?.properties.includeDirectories.default, ).toEqual([]); }); it('should have loadMemoryFromIncludeDirectories setting in schema', () => { expect( getSettingsSchema().context?.properties .loadMemoryFromIncludeDirectories, ).toBeDefined(); expect( getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .type, ).toBe('boolean'); expect( getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .category, ).toBe('Context'); expect( getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .default, ).toBe(false); }); it('should have folderTrustFeature setting in schema', () => { expect( getSettingsSchema().security.properties.folderTrust.properties.enabled, ).toBeDefined(); expect( getSettingsSchema().security.properties.folderTrust.properties.enabled .type, ).toBe('boolean'); expect( getSettingsSchema().security.properties.folderTrust.properties.enabled .category, ).toBe('Security'); expect( getSettingsSchema().security.properties.folderTrust.properties.enabled .default, ).toBe(true); expect( getSettingsSchema().security.properties.folderTrust.properties.enabled .showInDialog, ).toBe(true); }); it('should have debugKeystrokeLogging setting in schema', () => { expect( getSettingsSchema().general.properties.debugKeystrokeLogging, ).toBeDefined(); expect( getSettingsSchema().general.properties.debugKeystrokeLogging.type, ).toBe('boolean'); expect( getSettingsSchema().general.properties.debugKeystrokeLogging.category, ).toBe('General'); expect( getSettingsSchema().general.properties.debugKeystrokeLogging.default, ).toBe(false); expect( getSettingsSchema().general.properties.debugKeystrokeLogging .requiresRestart, ).toBe(false); expect( getSettingsSchema().general.properties.debugKeystrokeLogging .showInDialog, ).toBe(true); expect( getSettingsSchema().general.properties.debugKeystrokeLogging .description, ).toBe('Enable debug logging of keystrokes to the console.'); }); it('should have showShortcutsHint setting in schema', () => { expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined(); expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe( 'boolean', ); expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe( 'UI', ); expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe( true, ); expect( getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart, ).toBe(false); expect( getSettingsSchema().ui.properties.showShortcutsHint.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.showShortcutsHint.description, ).toBe('Show the "? for shortcuts" hint above the input.'); }); it('should have enableNotifications setting in schema', () => { const setting = getSettingsSchema().general.properties.enableNotifications; expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('General'); expect(setting.default).toBe(false); expect(setting.requiresRestart).toBe(false); expect(setting.showInDialog).toBe(true); }); it('should have enableAgents setting in schema', () => { const setting = getSettingsSchema().experimental.properties.enableAgents; expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(false); expect(setting.description).toBe('Enable local and remote subagents.'); }); it('should have skills setting enabled by default', () => { const setting = getSettingsSchema().skills.properties.enabled; expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Advanced'); expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(true); expect(setting.description).toBe('Enable Agent Skills.'); }); it('should have plan setting in schema', () => { const setting = getSettingsSchema().experimental.properties.plan; expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(true); expect(setting.description).toBe('Enable Plan Mode.'); }); it('should have hooksConfig.notifications setting in schema', () => { const setting = getSettingsSchema().hooksConfig?.properties.notifications; expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Advanced'); expect(setting.default).toBe(true); expect(setting.showInDialog).toBe(true); }); it('should have name and description in hook definitions', () => { const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray']; expect(hookDef).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const hookItemProperties = (hookDef as any).items.properties.hooks.items .properties; expect(hookItemProperties.name).toBeDefined(); expect(hookItemProperties.name.type).toBe('string'); expect(hookItemProperties.description).toBeDefined(); expect(hookItemProperties.description.type).toBe('string'); }); it('should have gemmaModelRouter setting in schema', () => { const gemmaModelRouter = getSettingsSchema().experimental.properties.gemmaModelRouter; expect(gemmaModelRouter).toBeDefined(); expect(gemmaModelRouter.type).toBe('object'); expect(gemmaModelRouter.category).toBe('Experimental'); expect(gemmaModelRouter.default).toEqual({}); expect(gemmaModelRouter.requiresRestart).toBe(true); expect(gemmaModelRouter.showInDialog).toBe(false); expect(gemmaModelRouter.description).toBe( 'Enable Gemma model router (experimental).', ); const enabled = gemmaModelRouter.properties.enabled; expect(enabled).toBeDefined(); expect(enabled.type).toBe('boolean'); expect(enabled.category).toBe('Experimental'); expect(enabled.default).toBe(false); expect(enabled.requiresRestart).toBe(true); expect(enabled.showInDialog).toBe(false); expect(enabled.description).toBe( 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', ); const classifier = gemmaModelRouter.properties.classifier; expect(classifier).toBeDefined(); expect(classifier.type).toBe('object'); expect(classifier.category).toBe('Experimental'); expect(classifier.default).toEqual({}); expect(classifier.requiresRestart).toBe(true); expect(classifier.showInDialog).toBe(false); expect(classifier.description).toBe('Classifier configuration.'); const host = classifier.properties.host; expect(host).toBeDefined(); expect(host.type).toBe('string'); expect(host.category).toBe('Experimental'); expect(host.default).toBe('http://localhost:9379'); expect(host.requiresRestart).toBe(true); expect(host.showInDialog).toBe(false); expect(host.description).toBe('The host of the classifier.'); const model = classifier.properties.model; expect(model).toBeDefined(); expect(model.type).toBe('string'); expect(model.category).toBe('Experimental'); expect(model.default).toBe('gemma3-1b-gpu-custom'); expect(model.requiresRestart).toBe(true); expect(model.showInDialog).toBe(false); expect(model.description).toBe( 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', ); }); }); it('has JSON schema definitions for every referenced ref', () => { const schema = getSettingsSchema(); const referenced = new Set(); const visitDefinition = (definition: SettingDefinition) => { if (definition.ref) { referenced.add(definition.ref); expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(definition.ref); } if (definition.properties) { Object.values(definition.properties).forEach(visitDefinition); } if (definition.items) { visitCollection(definition.items); } if (definition.additionalProperties) { visitCollection(definition.additionalProperties); } }; const visitCollection = (collection: SettingCollectionDefinition) => { if (collection.ref) { referenced.add(collection.ref); expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(collection.ref); return; } if (collection.properties) { Object.values(collection.properties).forEach(visitDefinition); } if (collection.type === 'array' && collection.properties) { Object.values(collection.properties).forEach(visitDefinition); } }; Object.values(schema).forEach(visitDefinition); // Ensure definitions map doesn't accumulate stale entries. Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { if (!referenced.has(key)) { throw new Error( `Definition "${key}" is exported but never referenced in the schema`, ); } }); }); }); ================================================ FILE: packages/cli/src/config/settingsSchema.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // -------------------------------------------------------------------------- // IMPORTANT: After adding or updating settings, run `npm run docs:settings` // to regenerate the settings reference in `docs/get-started/configuration.md`. // -------------------------------------------------------------------------- import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, AuthProviderType, type MCPServerConfig, type RequiredMcpServerConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, type AgentOverride, type CustomTheme, type SandboxConfig, } from '@google/gemini-cli-core'; import type { SessionRetentionSettings } from './settings.js'; import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; export type SettingsType = | 'boolean' | 'string' | 'number' | 'array' | 'object' | 'enum'; export type SettingsValue = | boolean | string | number | string[] | object | undefined; /** * Setting datatypes that "toggle" through a fixed list of options * (e.g. an enum or true/false) rather than allowing for free form input * (like a number or string). */ export const TOGGLE_TYPES: ReadonlySet = new Set([ 'boolean', 'enum', ]); export interface SettingEnumOption { value: string | number; label: string; } function oneLine(strings: TemplateStringsArray, ...values: unknown[]): string { let result = ''; for (let i = 0; i < strings.length; i++) { result += strings[i]; if (i < values.length) { result += String(values[i]); } } return result.replace(/\s+/g, ' ').trim(); } export interface SettingCollectionDefinition { type: SettingsType; description?: string; properties?: SettingsSchema; /** Enum type options */ options?: readonly SettingEnumOption[]; /** * Optional reference identifier for generators that emit a `$ref`. * For example, a JSON schema generator can use this to point to a shared definition. */ ref?: string; /** * Optional merge strategy for dynamically added properties. * Used when this collection definition is referenced via additionalProperties. */ mergeStrategy?: MergeStrategy; } export enum MergeStrategy { // Replace the old value with the new value. This is the default. REPLACE = 'replace', // Concatenate arrays. CONCAT = 'concat', // Merge arrays, ensuring unique values. UNION = 'union', // Shallow merge objects. SHALLOW_MERGE = 'shallow_merge', } export interface SettingDefinition { type: SettingsType; label: string; category: string; requiresRestart: boolean; default: SettingsValue; description?: string; parentKey?: string; childKey?: string; key?: string; properties?: SettingsSchema; showInDialog?: boolean; ignoreInDocs?: boolean; mergeStrategy?: MergeStrategy; /** Enum type options */ options?: readonly SettingEnumOption[]; /** * For collection types (e.g. arrays), describes the shape of each item. */ items?: SettingCollectionDefinition; /** * For map-like objects without explicit `properties`, describes the shape of the values. */ additionalProperties?: SettingCollectionDefinition; /** * Optional unit to display after the value (e.g. '%'). */ unit?: string; /** * Optional reference identifier for generators that emit a `$ref`. */ ref?: string; } export interface SettingsSchema { [key: string]: SettingDefinition; } export type MemoryImportFormat = 'tree' | 'flat'; export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; const pathArraySetting = (label: string, description: string) => ({ type: 'array' as const, label, category: 'Advanced' as const, requiresRestart: true as const, default: [] as string[], description, showInDialog: false as const, items: { type: 'string' as const }, mergeStrategy: MergeStrategy.UNION, }); /** * The canonical schema for all settings. * The structure of this object defines the structure of the `Settings` type. * `as const` is crucial for TypeScript to infer the most specific types possible. */ const SETTINGS_SCHEMA = { // Maintained for compatibility/criticality mcpServers: { type: 'object', label: 'MCP Servers', category: 'Advanced', requiresRestart: true, default: {} as Record, description: 'Configuration for MCP servers.', showInDialog: false, mergeStrategy: MergeStrategy.SHALLOW_MERGE, additionalProperties: { type: 'object', ref: 'MCPServerConfig', }, }, policyPaths: pathArraySetting( 'Policy Paths', 'Additional policy files or directories to load.', ), adminPolicyPaths: pathArraySetting( 'Admin Policy Paths', 'Additional admin policy files or directories to load.', ), general: { type: 'object', label: 'General', category: 'General', requiresRestart: false, default: {}, description: 'General application settings.', showInDialog: false, properties: { preferredEditor: { type: 'string', label: 'Preferred Editor', category: 'General', requiresRestart: false, default: undefined as string | undefined, description: 'The preferred editor to open files in.', showInDialog: false, }, vimMode: { type: 'boolean', label: 'Vim Mode', category: 'General', requiresRestart: false, default: false, description: 'Enable Vim keybindings', showInDialog: true, }, defaultApprovalMode: { type: 'enum', label: 'Default Approval Mode', category: 'General', requiresRestart: false, default: 'default', description: oneLine` The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). `, showInDialog: true, options: [ { value: 'default', label: 'Default' }, { value: 'auto_edit', label: 'Auto Edit' }, { value: 'plan', label: 'Plan' }, ], }, devtools: { type: 'boolean', label: 'DevTools', category: 'General', requiresRestart: false, default: false, description: 'Enable DevTools inspector on launch.', showInDialog: false, }, enableAutoUpdate: { type: 'boolean', label: 'Enable Auto Update', category: 'General', requiresRestart: false, default: true, description: 'Enable automatic updates.', showInDialog: true, }, enableAutoUpdateNotification: { type: 'boolean', label: 'Enable Auto Update Notification', category: 'General', requiresRestart: false, default: true, description: 'Enable update notification prompts.', showInDialog: false, }, enableNotifications: { type: 'boolean', label: 'Enable Notifications', category: 'General', requiresRestart: false, default: false, description: 'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.', showInDialog: true, }, checkpointing: { type: 'object', label: 'Checkpointing', category: 'General', requiresRestart: true, default: {}, description: 'Session checkpointing settings.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Checkpointing', category: 'General', requiresRestart: true, default: false, description: 'Enable session checkpointing for recovery', showInDialog: false, }, }, }, plan: { type: 'object', label: 'Plan', category: 'General', requiresRestart: true, default: {}, description: 'Planning features configuration.', showInDialog: false, properties: { directory: { type: 'string', label: 'Plan Directory', category: 'General', requiresRestart: true, default: undefined as string | undefined, description: 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', showInDialog: true, }, modelRouting: { type: 'boolean', label: 'Plan Model Routing', category: 'General', requiresRestart: false, default: true, description: 'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.', showInDialog: true, }, }, }, retryFetchErrors: { type: 'boolean', label: 'Retry Fetch Errors', category: 'General', requiresRestart: false, default: true, description: 'Retry on "exception TypeError: fetch failed sending request" errors.', showInDialog: true, }, maxAttempts: { type: 'number', label: 'Max Chat Model Attempts', category: 'General', requiresRestart: false, default: 10, description: 'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.', showInDialog: true, }, debugKeystrokeLogging: { type: 'boolean', label: 'Debug Keystroke Logging', category: 'General', requiresRestart: false, default: false, description: 'Enable debug logging of keystrokes to the console.', showInDialog: true, }, sessionRetention: { type: 'object', label: 'Session Retention', category: 'General', requiresRestart: false, default: undefined as SessionRetentionSettings | undefined, showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Session Cleanup', category: 'General', requiresRestart: false, default: true as boolean, description: 'Enable automatic session cleanup', showInDialog: true, }, maxAge: { type: 'string', label: 'Keep chat history', category: 'General', requiresRestart: false, default: '30d' as string, description: 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', showInDialog: true, }, maxCount: { type: 'number', label: 'Max Session Count', category: 'General', requiresRestart: false, default: undefined as number | undefined, description: 'Alternative: Maximum number of sessions to keep (most recent)', showInDialog: false, }, minRetention: { type: 'string', label: 'Min Retention Period', category: 'General', requiresRestart: false, default: DEFAULT_MIN_RETENTION, description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, }, description: 'Settings for automatic session cleanup.', }, }, }, output: { type: 'object', label: 'Output', category: 'General', requiresRestart: false, default: {}, description: 'Settings for the CLI output.', showInDialog: false, properties: { format: { type: 'enum', label: 'Output Format', category: 'General', requiresRestart: false, default: 'text', description: 'The format of the CLI output. Can be `text` or `json`.', showInDialog: true, options: [ { value: 'text', label: 'Text' }, { value: 'json', label: 'JSON' }, ], }, }, }, ui: { type: 'object', label: 'UI', category: 'UI', requiresRestart: false, default: {}, description: 'User interface settings.', showInDialog: false, properties: { theme: { type: 'string', label: 'Theme', category: 'UI', requiresRestart: false, default: undefined as string | undefined, description: 'The color theme for the UI. See the CLI themes guide for available options.', showInDialog: false, }, autoThemeSwitching: { type: 'boolean', label: 'Auto Theme Switching', category: 'UI', requiresRestart: false, default: true, description: 'Automatically switch between default light and dark themes based on terminal background color.', showInDialog: true, }, terminalBackgroundPollingInterval: { type: 'number', label: 'Terminal Background Polling Interval', category: 'UI', requiresRestart: false, default: 60, description: 'Interval in seconds to poll the terminal background color.', showInDialog: true, }, customThemes: { type: 'object', label: 'Custom Themes', category: 'UI', requiresRestart: false, default: {} as Record, description: 'Custom theme definitions.', showInDialog: false, additionalProperties: { type: 'object', ref: 'CustomTheme', }, }, hideWindowTitle: { type: 'boolean', label: 'Hide Window Title', category: 'UI', requiresRestart: true, default: false, description: 'Hide the window title bar', showInDialog: true, }, inlineThinkingMode: { type: 'enum', label: 'Inline Thinking', category: 'UI', requiresRestart: false, default: 'off', description: 'Display model thinking inline: off or full.', showInDialog: true, options: [ { value: 'off', label: 'Off' }, { value: 'full', label: 'Full' }, ], }, showStatusInTitle: { type: 'boolean', label: 'Show Thoughts in Title', category: 'UI', requiresRestart: false, default: false, description: 'Show Gemini CLI model thoughts in the terminal window title during the working phase', showInDialog: true, }, dynamicWindowTitle: { type: 'boolean', label: 'Dynamic Window Title', category: 'UI', requiresRestart: false, default: true, description: 'Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)', showInDialog: true, }, showHomeDirectoryWarning: { type: 'boolean', label: 'Show Home Directory Warning', category: 'UI', requiresRestart: true, default: true, description: 'Show a warning when running Gemini CLI in the home directory.', showInDialog: true, }, showCompatibilityWarnings: { type: 'boolean', label: 'Show Compatibility Warnings', category: 'UI', requiresRestart: true, default: true, description: 'Show warnings about terminal or OS compatibility issues.', showInDialog: true, }, hideTips: { type: 'boolean', label: 'Hide Tips', category: 'UI', requiresRestart: false, default: false, description: 'Hide helpful tips in the UI', showInDialog: true, }, escapePastedAtSymbols: { type: 'boolean', label: 'Escape Pasted @ Symbols', category: 'UI', requiresRestart: false, default: false, description: 'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.', showInDialog: true, }, showShortcutsHint: { type: 'boolean', label: 'Show Shortcuts Hint', category: 'UI', requiresRestart: false, default: true, description: 'Show the "? for shortcuts" hint above the input.', showInDialog: true, }, hideBanner: { type: 'boolean', label: 'Hide Banner', category: 'UI', requiresRestart: false, default: false, description: 'Hide the application banner', showInDialog: true, }, hideContextSummary: { type: 'boolean', label: 'Hide Context Summary', category: 'UI', requiresRestart: false, default: false, description: 'Hide the context summary (GEMINI.md, MCP servers) above the input.', showInDialog: true, }, footer: { type: 'object', label: 'Footer', category: 'UI', requiresRestart: false, default: {}, description: 'Settings for the footer.', showInDialog: false, properties: { items: { type: 'array', label: 'Footer Items', category: 'UI', requiresRestart: false, default: undefined as string[] | undefined, description: 'List of item IDs to display in the footer. Rendered in order', showInDialog: false, items: { type: 'string' }, }, showLabels: { type: 'boolean', label: 'Show Footer Labels', category: 'UI', requiresRestart: false, default: true, description: 'Display a second line above the footer items with descriptive headers (e.g., /model).', showInDialog: false, }, hideCWD: { type: 'boolean', label: 'Hide CWD', category: 'UI', requiresRestart: false, default: false, description: 'Hide the current working directory in the footer.', showInDialog: true, }, hideSandboxStatus: { type: 'boolean', label: 'Hide Sandbox Status', category: 'UI', requiresRestart: false, default: false, description: 'Hide the sandbox status indicator in the footer.', showInDialog: true, }, hideModelInfo: { type: 'boolean', label: 'Hide Model Info', category: 'UI', requiresRestart: false, default: false, description: 'Hide the model name and context usage in the footer.', showInDialog: true, }, hideContextPercentage: { type: 'boolean', label: 'Hide Context Window Percentage', category: 'UI', requiresRestart: false, default: true, description: 'Hides the context window usage percentage.', showInDialog: true, }, }, }, hideFooter: { type: 'boolean', label: 'Hide Footer', category: 'UI', requiresRestart: false, default: false, description: 'Hide the footer from the UI', showInDialog: true, }, showMemoryUsage: { type: 'boolean', label: 'Show Memory Usage', category: 'UI', requiresRestart: false, default: false, description: 'Display memory usage information in the UI', showInDialog: true, }, showLineNumbers: { type: 'boolean', label: 'Show Line Numbers', category: 'UI', requiresRestart: false, default: true, description: 'Show line numbers in the chat.', showInDialog: true, }, showCitations: { type: 'boolean', label: 'Show Citations', category: 'UI', requiresRestart: false, default: false, description: 'Show citations for generated text in the chat.', showInDialog: true, }, showModelInfoInChat: { type: 'boolean', label: 'Show Model Info In Chat', category: 'UI', requiresRestart: false, default: false, description: 'Show the model name in the chat for each model turn.', showInDialog: true, }, showUserIdentity: { type: 'boolean', label: 'Show User Identity', category: 'UI', requiresRestart: false, default: true, description: "Show the signed-in user's identity (e.g. email) in the UI.", showInDialog: true, }, useAlternateBuffer: { type: 'boolean', label: 'Use Alternate Screen Buffer', category: 'UI', requiresRestart: true, default: false, description: 'Use an alternate screen buffer for the UI, preserving shell history.', showInDialog: true, }, useBackgroundColor: { type: 'boolean', label: 'Use Background Color', category: 'UI', requiresRestart: false, default: true, description: 'Whether to use background colors in the UI.', showInDialog: true, }, incrementalRendering: { type: 'boolean', label: 'Incremental Rendering', category: 'UI', requiresRestart: true, default: true, description: 'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.', showInDialog: true, }, showSpinner: { type: 'boolean', label: 'Show Spinner', category: 'UI', requiresRestart: false, default: true, description: 'Show the spinner during operations.', showInDialog: true, }, loadingPhrases: { type: 'enum', label: 'Loading Phrases', category: 'UI', requiresRestart: false, default: 'tips', description: 'What to show while the model is working: tips, witty comments, both, or nothing.', showInDialog: true, options: [ { value: 'tips', label: 'Tips' }, { value: 'witty', label: 'Witty' }, { value: 'all', label: 'All' }, { value: 'off', label: 'Off' }, ], }, errorVerbosity: { type: 'enum', label: 'Error Verbosity', category: 'UI', requiresRestart: false, default: 'low', description: 'Controls whether recoverable errors are hidden (low) or fully shown (full).', showInDialog: true, options: [ { value: 'low', label: 'Low' }, { value: 'full', label: 'Full' }, ], }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', category: 'UI', requiresRestart: false, default: [] as string[], description: oneLine` Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. `, showInDialog: false, items: { type: 'string' }, }, accessibility: { type: 'object', label: 'Accessibility', category: 'UI', requiresRestart: true, default: {}, description: 'Accessibility settings.', showInDialog: false, properties: { enableLoadingPhrases: { type: 'boolean', label: 'Enable Loading Phrases', category: 'UI', requiresRestart: true, default: true, description: '@deprecated Use ui.loadingPhrases instead. Enable loading phrases during operations.', showInDialog: false, }, screenReader: { type: 'boolean', label: 'Screen Reader Mode', category: 'UI', requiresRestart: true, default: false, description: 'Render output in plain-text to be more screen reader accessible', showInDialog: true, }, }, }, }, }, ide: { type: 'object', label: 'IDE', category: 'IDE', requiresRestart: true, default: {}, description: 'IDE integration settings.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'IDE Mode', category: 'IDE', requiresRestart: true, default: false, description: 'Enable IDE integration mode.', showInDialog: true, }, hasSeenNudge: { type: 'boolean', label: 'Has Seen IDE Integration Nudge', category: 'IDE', requiresRestart: false, default: false, description: 'Whether the user has seen the IDE integration nudge.', showInDialog: false, }, }, }, privacy: { type: 'object', label: 'Privacy', category: 'Privacy', requiresRestart: true, default: {}, description: 'Privacy-related settings.', showInDialog: false, properties: { usageStatisticsEnabled: { type: 'boolean', label: 'Enable Usage Statistics', category: 'Privacy', requiresRestart: true, default: true, description: 'Enable collection of usage statistics', showInDialog: false, }, }, }, telemetry: { type: 'object', label: 'Telemetry', category: 'Advanced', requiresRestart: true, default: undefined as TelemetrySettings | undefined, description: 'Telemetry configuration.', showInDialog: false, ref: 'TelemetrySettings', }, billing: { type: 'object', label: 'Billing', category: 'Advanced', requiresRestart: false, default: {}, description: 'Billing and AI credits settings.', showInDialog: false, properties: { overageStrategy: { type: 'enum', label: 'Overage Strategy', category: 'Advanced', requiresRestart: false, default: 'ask', description: oneLine` How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. `, showInDialog: true, options: [ { value: 'ask', label: 'Ask each time' }, { value: 'always', label: 'Always use credits' }, { value: 'never', label: 'Never use credits' }, ], }, }, }, model: { type: 'object', label: 'Model', category: 'Model', requiresRestart: false, default: {}, description: 'Settings related to the generative model.', showInDialog: false, properties: { name: { type: 'string', label: 'Model', category: 'Model', requiresRestart: false, default: undefined as string | undefined, description: 'The Gemini model to use for conversations.', showInDialog: true, }, maxSessionTurns: { type: 'number', label: 'Max Session Turns', category: 'Model', requiresRestart: false, default: -1, description: 'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.', showInDialog: true, }, summarizeToolOutput: { type: 'object', label: 'Summarize Tool Output', category: 'Model', requiresRestart: false, default: undefined as | Record | undefined, description: oneLine` Enables or disables summarization of tool output. Configure per-tool token budgets (for example {"run_shell_command": {"tokenBudget": 2000}}). Currently only the run_shell_command tool supports summarization. `, showInDialog: false, additionalProperties: { type: 'object', description: 'Per-tool summarization settings with an optional tokenBudget.', ref: 'SummarizeToolOutputSettings', }, }, compressionThreshold: { type: 'number', label: 'Context Compression Threshold', category: 'Model', requiresRestart: true, default: 0.5 as number, description: 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', showInDialog: true, unit: '%', }, disableLoopDetection: { type: 'boolean', label: 'Disable Loop Detection', category: 'Model', requiresRestart: true, default: false, description: 'Disable automatic detection and prevention of infinite loops.', showInDialog: true, }, skipNextSpeakerCheck: { type: 'boolean', label: 'Skip Next Speaker Check', category: 'Model', requiresRestart: false, default: true, description: 'Skip the next speaker check.', showInDialog: true, }, }, }, modelConfigs: { type: 'object', label: 'Model Configs', category: 'Model', requiresRestart: false, default: DEFAULT_MODEL_CONFIGS, description: 'Model configurations.', showInDialog: false, properties: { aliases: { type: 'object', label: 'Model Config Aliases', category: 'Model', requiresRestart: false, default: DEFAULT_MODEL_CONFIGS.aliases, description: 'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.', showInDialog: false, }, customAliases: { type: 'object', label: 'Custom Model Config Aliases', category: 'Model', requiresRestart: false, default: {}, description: 'Custom named presets for model configs. These are merged with (and override) the built-in aliases.', showInDialog: false, }, customOverrides: { type: 'array', label: 'Custom Model Config Overrides', category: 'Model', requiresRestart: false, default: [], description: 'Custom model config overrides. These are merged with (and added to) the built-in overrides.', showInDialog: false, }, overrides: { type: 'array', label: 'Model Config Overrides', category: 'Model', requiresRestart: false, default: [], description: 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', showInDialog: false, }, modelDefinitions: { type: 'object', label: 'Model Definitions', category: 'Model', requiresRestart: true, default: DEFAULT_MODEL_CONFIGS.modelDefinitions, description: 'Registry of model metadata, including tier, family, and features.', showInDialog: false, additionalProperties: { type: 'object', ref: 'ModelDefinition', }, }, modelIdResolutions: { type: 'object', label: 'Model ID Resolutions', category: 'Model', requiresRestart: true, default: DEFAULT_MODEL_CONFIGS.modelIdResolutions, description: 'Rules for resolving requested model names to concrete model IDs based on context.', showInDialog: false, additionalProperties: { type: 'object', ref: 'ModelResolution', }, }, classifierIdResolutions: { type: 'object', label: 'Classifier ID Resolutions', category: 'Model', requiresRestart: true, default: DEFAULT_MODEL_CONFIGS.classifierIdResolutions, description: 'Rules for resolving classifier tiers (flash, pro) to concrete model IDs.', showInDialog: false, additionalProperties: { type: 'object', ref: 'ModelResolution', }, }, modelChains: { type: 'object', label: 'Model Chains', category: 'Model', requiresRestart: true, default: DEFAULT_MODEL_CONFIGS.modelChains, description: 'Availability policy chains defining fallback behavior for models.', showInDialog: false, additionalProperties: { type: 'array', ref: 'ModelPolicy', }, }, }, }, agents: { type: 'object', label: 'Agents', category: 'Advanced', requiresRestart: true, default: {}, description: 'Settings for subagents.', showInDialog: false, properties: { overrides: { type: 'object', label: 'Agent Overrides', category: 'Advanced', requiresRestart: true, default: {} as Record, description: 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', showInDialog: false, additionalProperties: { type: 'object', ref: 'AgentOverride', }, }, browser: { type: 'object', label: 'Browser Agent', category: 'Advanced', requiresRestart: true, default: {}, description: 'Settings specific to the browser agent.', showInDialog: false, properties: { sessionMode: { type: 'enum', label: 'Browser Session Mode', category: 'Advanced', requiresRestart: true, default: 'persistent', description: "Session mode: 'persistent', 'isolated', or 'existing'.", showInDialog: false, options: [ { value: 'persistent', label: 'Persistent' }, { value: 'isolated', label: 'Isolated' }, { value: 'existing', label: 'Existing' }, ], }, headless: { type: 'boolean', label: 'Browser Headless', category: 'Advanced', requiresRestart: true, default: false, description: 'Run browser in headless mode.', showInDialog: false, }, profilePath: { type: 'string', label: 'Browser Profile Path', category: 'Advanced', requiresRestart: true, default: undefined as string | undefined, description: 'Path to browser profile directory for session persistence.', showInDialog: false, }, visualModel: { type: 'string', label: 'Browser Visual Model', category: 'Advanced', requiresRestart: true, default: undefined as string | undefined, description: 'Model override for the visual agent.', showInDialog: false, }, allowedDomains: { type: 'array', label: 'Allowed Domains', category: 'Advanced', requiresRestart: true, default: ['github.com', '*.google.com', 'localhost'] as string[], description: oneLine` A list of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). `, showInDialog: false, items: { type: 'string' }, }, disableUserInput: { type: 'boolean', label: 'Disable User Input', category: 'Advanced', requiresRestart: false, default: true, description: 'Disable user input on browser window during automation.', showInDialog: false, }, }, }, }, }, context: { type: 'object', label: 'Context', category: 'Context', requiresRestart: false, default: {}, description: 'Settings for managing context provided to the model.', showInDialog: false, properties: { fileName: { type: 'string', label: 'Context File Name', category: 'Context', requiresRestart: false, default: undefined as string | string[] | undefined, ref: 'StringOrStringArray', description: 'The name of the context file or files to load into memory. Accepts either a single string or an array of strings.', showInDialog: false, }, importFormat: { type: 'string', label: 'Memory Import Format', category: 'Context', requiresRestart: false, default: undefined as MemoryImportFormat | undefined, description: 'The format to use when importing memory.', showInDialog: false, }, includeDirectoryTree: { type: 'boolean', label: 'Include Directory Tree', category: 'Context', requiresRestart: false, default: true, description: 'Whether to include the directory tree of the current working directory in the initial request to the model.', showInDialog: false, }, discoveryMaxDirs: { type: 'number', label: 'Memory Discovery Max Dirs', category: 'Context', requiresRestart: false, default: 200, description: 'Maximum number of directories to search for memory.', showInDialog: true, }, includeDirectories: { type: 'array', label: 'Include Directories', category: 'Context', requiresRestart: false, default: [] as string[], description: oneLine` Additional directories to include in the workspace context. Missing directories will be skipped with a warning. `, showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.CONCAT, }, loadMemoryFromIncludeDirectories: { type: 'boolean', label: 'Load Memory From Include Directories', category: 'Context', requiresRestart: false, default: false, description: oneLine` Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. `, showInDialog: true, }, fileFiltering: { type: 'object', label: 'File Filtering', category: 'Context', requiresRestart: true, default: {}, description: 'Settings for git-aware file filtering.', showInDialog: false, properties: { respectGitIgnore: { type: 'boolean', label: 'Respect .gitignore', category: 'Context', requiresRestart: true, default: true, description: 'Respect .gitignore files when searching.', showInDialog: true, }, respectGeminiIgnore: { type: 'boolean', label: 'Respect .geminiignore', category: 'Context', requiresRestart: true, default: true, description: 'Respect .geminiignore files when searching.', showInDialog: true, }, enableRecursiveFileSearch: { type: 'boolean', label: 'Enable Recursive File Search', category: 'Context', requiresRestart: true, default: true, description: oneLine` Enable recursive file search functionality when completing @ references in the prompt. `, showInDialog: true, }, enableFuzzySearch: { type: 'boolean', label: 'Enable Fuzzy Search', category: 'Context', requiresRestart: true, default: true, description: 'Enable fuzzy search when searching for files.', showInDialog: true, }, customIgnoreFilePaths: { type: 'array', label: 'Custom Ignore File Paths', category: 'Context', requiresRestart: true, default: [] as string[], description: 'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.', showInDialog: true, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, }, }, }, }, tools: { type: 'object', label: 'Tools', category: 'Tools', requiresRestart: true, default: {}, description: 'Settings for built-in and custom tools.', showInDialog: false, properties: { sandbox: { type: 'string', label: 'Sandbox', category: 'Tools', requiresRestart: true, default: undefined as boolean | string | SandboxConfig | undefined, ref: 'BooleanOrStringOrObject', description: oneLine` Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, sandboxAllowedPaths: { type: 'array', label: 'Sandbox Allowed Paths', category: 'Tools', requiresRestart: true, default: [] as string[], description: 'List of additional paths that the sandbox is allowed to access.', showInDialog: true, items: { type: 'string' }, }, sandboxNetworkAccess: { type: 'boolean', label: 'Sandbox Network Access', category: 'Tools', requiresRestart: true, default: false, description: 'Whether the sandbox is allowed to access the network.', showInDialog: true, }, shell: { type: 'object', label: 'Shell', category: 'Tools', requiresRestart: false, default: {}, description: 'Settings for shell execution.', showInDialog: false, properties: { enableInteractiveShell: { type: 'boolean', label: 'Enable Interactive Shell', category: 'Tools', requiresRestart: true, default: true, description: oneLine` Use node-pty for an interactive shell experience. Fallback to child_process still applies. `, showInDialog: true, }, pager: { type: 'string', label: 'Pager', category: 'Tools', requiresRestart: false, default: 'cat' as string | undefined, description: 'The pager command to use for shell output. Defaults to `cat`.', showInDialog: false, }, showColor: { type: 'boolean', label: 'Show Color', category: 'Tools', requiresRestart: false, default: false, description: 'Show color in shell output.', showInDialog: true, }, inactivityTimeout: { type: 'number', label: 'Inactivity Timeout', category: 'Tools', requiresRestart: false, default: 300, description: 'The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.', showInDialog: false, }, enableShellOutputEfficiency: { type: 'boolean', label: 'Enable Shell Output Efficiency', category: 'Tools', requiresRestart: false, default: true, description: 'Enable shell output efficiency optimizations for better performance.', showInDialog: false, }, }, }, core: { type: 'array', label: 'Core Tools', category: 'Tools', requiresRestart: true, default: undefined as string[] | undefined, description: oneLine` Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for available names. `, showInDialog: false, items: { type: 'string' }, }, allowed: { type: 'array', label: 'Allowed Tools', category: 'Advanced', requiresRestart: true, default: undefined as string[] | undefined, description: oneLine` Tool names that bypass the confirmation dialog. Useful for trusted commands (for example ["run_shell_command(git)", "run_shell_command(npm test)"]). See shell tool command restrictions for matching details. `, showInDialog: false, items: { type: 'string' }, }, exclude: { type: 'array', label: 'Exclude Tools', category: 'Tools', requiresRestart: true, default: undefined as string[] | undefined, description: 'Tool names to exclude from discovery.', showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, discoveryCommand: { type: 'string', label: 'Tool Discovery Command', category: 'Tools', requiresRestart: true, default: undefined as string | undefined, description: 'Command to run for tool discovery.', showInDialog: false, }, callCommand: { type: 'string', label: 'Tool Call Command', category: 'Tools', requiresRestart: true, default: undefined as string | undefined, description: oneLine` Defines a custom shell command for invoking discovered tools. The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout. `, showInDialog: false, }, useRipgrep: { type: 'boolean', label: 'Use Ripgrep', category: 'Tools', requiresRestart: false, default: true, description: 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', showInDialog: true, }, truncateToolOutputThreshold: { type: 'number', label: 'Tool Output Truncation Threshold', category: 'General', requiresRestart: true, default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, description: 'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.', showInDialog: true, }, disableLLMCorrection: { type: 'boolean', label: 'Disable LLM Correction', category: 'Tools', requiresRestart: true, default: true, description: oneLine` Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. `, showInDialog: true, }, }, }, mcp: { type: 'object', label: 'MCP', category: 'MCP', requiresRestart: true, default: {}, description: 'Settings for Model Context Protocol (MCP) servers.', showInDialog: false, properties: { serverCommand: { type: 'string', label: 'MCP Server Command', category: 'MCP', requiresRestart: true, default: undefined as string | undefined, description: 'Command to start an MCP server.', showInDialog: false, }, allowed: { type: 'array', label: 'Allow MCP Servers', category: 'MCP', requiresRestart: true, default: undefined as string[] | undefined, description: 'A list of MCP servers to allow.', showInDialog: false, items: { type: 'string' }, }, excluded: { type: 'array', label: 'Exclude MCP Servers', category: 'MCP', requiresRestart: true, default: undefined as string[] | undefined, description: 'A list of MCP servers to exclude.', showInDialog: false, items: { type: 'string' }, }, }, }, useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', category: 'Advanced', requiresRestart: false, default: true, description: 'Enable the write_todos tool.', showInDialog: false, }, security: { type: 'object', label: 'Security', category: 'Security', requiresRestart: true, default: {}, description: 'Security-related settings.', showInDialog: false, properties: { toolSandboxing: { type: 'boolean', label: 'Tool Sandboxing', category: 'Security', requiresRestart: false, default: false, description: 'Experimental tool-level sandboxing (implementation in progress).', showInDialog: true, }, disableYoloMode: { type: 'boolean', label: 'Disable YOLO Mode', category: 'Security', requiresRestart: true, default: false, description: 'Disable YOLO mode, even if enabled by a flag.', showInDialog: true, }, disableAlwaysAllow: { type: 'boolean', label: 'Disable Always Allow', category: 'Security', requiresRestart: true, default: false, description: 'Disable "Always allow" options in tool confirmation dialogs.', showInDialog: true, }, enablePermanentToolApproval: { type: 'boolean', label: 'Allow Permanent Tool Approval', category: 'Security', requiresRestart: false, default: false, description: 'Enable the "Allow for all future sessions" option in tool confirmation dialogs.', showInDialog: true, }, autoAddToPolicyByDefault: { type: 'boolean', label: 'Auto-add to Policy by Default', category: 'Security', requiresRestart: false, default: false, description: oneLine` When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. `, showInDialog: true, }, blockGitExtensions: { type: 'boolean', label: 'Blocks extensions from Git', category: 'Security', requiresRestart: true, default: false, description: 'Blocks installing and loading extensions from Git.', showInDialog: true, }, allowedExtensions: { type: 'array', label: 'Extension Source Regex Allowlist', category: 'Security', requiresRestart: true, default: [] as string[], description: 'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.', showInDialog: true, items: { type: 'string' }, }, folderTrust: { type: 'object', label: 'Folder Trust', category: 'Security', requiresRestart: false, default: {}, description: 'Settings for folder trust.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Folder Trust', category: 'Security', requiresRestart: true, default: true, description: 'Setting to track whether Folder trust is enabled.', showInDialog: true, }, }, }, environmentVariableRedaction: { type: 'object', label: 'Environment Variable Redaction', category: 'Security', requiresRestart: false, default: {}, description: 'Settings for environment variable redaction.', showInDialog: false, properties: { allowed: { type: 'array', label: 'Allowed Environment Variables', category: 'Security', requiresRestart: true, default: [] as string[], description: 'Environment variables to always allow (bypass redaction).', showInDialog: false, items: { type: 'string' }, }, blocked: { type: 'array', label: 'Blocked Environment Variables', category: 'Security', requiresRestart: true, default: [] as string[], description: 'Environment variables to always redact.', showInDialog: false, items: { type: 'string' }, }, enabled: { type: 'boolean', label: 'Enable Environment Variable Redaction', category: 'Security', requiresRestart: true, default: false, description: 'Enable redaction of environment variables that may contain secrets.', showInDialog: true, }, }, }, auth: { type: 'object', label: 'Authentication', category: 'Security', requiresRestart: true, default: {}, description: 'Authentication settings.', showInDialog: false, properties: { selectedType: { type: 'string', label: 'Selected Auth Type', category: 'Security', requiresRestart: true, default: undefined as AuthType | undefined, description: 'The currently selected authentication type.', showInDialog: false, }, enforcedType: { type: 'string', label: 'Enforced Auth Type', category: 'Advanced', requiresRestart: true, default: undefined as AuthType | undefined, description: 'The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.', showInDialog: false, }, useExternal: { type: 'boolean', label: 'Use External Auth', category: 'Security', requiresRestart: true, default: undefined as boolean | undefined, description: 'Whether to use an external authentication flow.', showInDialog: false, }, }, }, enableConseca: { type: 'boolean', label: 'Enable Context-Aware Security', category: 'Security', requiresRestart: true, default: false, description: 'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.', showInDialog: true, }, }, }, advanced: { type: 'object', label: 'Advanced', category: 'Advanced', requiresRestart: true, default: {}, description: 'Advanced settings for power users.', showInDialog: false, properties: { autoConfigureMemory: { type: 'boolean', label: 'Auto Configure Max Old Space Size', category: 'Advanced', requiresRestart: true, default: false, description: 'Automatically configure Node.js memory limits', showInDialog: true, }, dnsResolutionOrder: { type: 'string', label: 'DNS Resolution Order', category: 'Advanced', requiresRestart: true, default: undefined as DnsResolutionOrder | undefined, description: 'The DNS resolution order.', showInDialog: false, }, excludedEnvVars: { type: 'array', label: 'Excluded Project Environment Variables', category: 'Advanced', requiresRestart: false, default: ['DEBUG', 'DEBUG_MODE'] as string[], description: 'Environment variables to exclude from project context.', showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, bugCommand: { type: 'object', label: 'Bug Command', category: 'Advanced', requiresRestart: false, default: undefined as BugCommandSettings | undefined, description: 'Configuration for the bug report command.', showInDialog: false, ref: 'BugCommandSettings', }, }, }, experimental: { type: 'object', label: 'Experimental', category: 'Experimental', requiresRestart: true, default: {}, description: 'Setting to enable experimental features', showInDialog: false, properties: { toolOutputMasking: { type: 'object', label: 'Tool Output Masking', category: 'Experimental', requiresRestart: true, ignoreInDocs: false, default: {}, description: 'Advanced settings for tool output masking to manage context window efficiency.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Tool Output Masking', category: 'Experimental', requiresRestart: true, default: true, description: 'Enables tool output masking to save tokens.', showInDialog: true, }, toolProtectionThreshold: { type: 'number', label: 'Tool Protection Threshold', category: 'Experimental', requiresRestart: true, default: 50000, description: 'Minimum number of tokens to protect from masking (most recent tool outputs).', showInDialog: false, }, minPrunableTokensThreshold: { type: 'number', label: 'Min Prunable Tokens Threshold', category: 'Experimental', requiresRestart: true, default: 30000, description: 'Minimum prunable tokens required to trigger a masking pass.', showInDialog: false, }, protectLatestTurn: { type: 'boolean', label: 'Protect Latest Turn', category: 'Experimental', requiresRestart: true, default: true, description: 'Ensures the absolute latest turn is never masked, regardless of token count.', showInDialog: false, }, }, }, enableAgents: { type: 'boolean', label: 'Enable Agents', category: 'Experimental', requiresRestart: true, default: true, description: 'Enable local and remote subagents.', showInDialog: false, }, extensionManagement: { type: 'boolean', label: 'Extension Management', category: 'Experimental', requiresRestart: true, default: true, description: 'Enable extension management features.', showInDialog: false, }, extensionConfig: { type: 'boolean', label: 'Extension Configuration', category: 'Experimental', requiresRestart: true, default: true, description: 'Enable requesting and fetching of extension settings.', showInDialog: false, }, extensionRegistry: { type: 'boolean', label: 'Extension Registry Explore UI', category: 'Experimental', requiresRestart: true, default: false, description: 'Enable extension registry explore UI.', showInDialog: false, }, extensionRegistryURI: { type: 'string', label: 'Extension Registry URI', category: 'Experimental', requiresRestart: true, default: 'https://geminicli.com/extensions.json', description: 'The URI (web URL or local file path) of the extension registry.', showInDialog: false, }, extensionReloading: { type: 'boolean', label: 'Extension Reloading', category: 'Experimental', requiresRestart: true, default: false, description: 'Enables extension loading/unloading within the CLI session.', showInDialog: false, }, jitContext: { type: 'boolean', label: 'JIT Context Loading', category: 'Experimental', requiresRestart: true, default: true, description: 'Enable Just-In-Time (JIT) context loading.', showInDialog: false, }, useOSC52Paste: { type: 'boolean', label: 'Use OSC 52 Paste', category: 'Experimental', requiresRestart: false, default: false, description: 'Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', showInDialog: true, }, useOSC52Copy: { type: 'boolean', label: 'Use OSC 52 Copy', category: 'Experimental', requiresRestart: false, default: false, description: 'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).', showInDialog: true, }, plan: { type: 'boolean', label: 'Plan', category: 'Experimental', requiresRestart: true, default: true, description: 'Enable Plan Mode.', showInDialog: true, }, taskTracker: { type: 'boolean', label: 'Task Tracker', category: 'Experimental', requiresRestart: true, default: false, description: 'Enable task tracker tools.', showInDialog: false, }, modelSteering: { type: 'boolean', label: 'Model Steering', category: 'Experimental', requiresRestart: false, default: false, description: 'Enable model steering (user hints) to guide the model during tool execution.', showInDialog: true, }, directWebFetch: { type: 'boolean', label: 'Direct Web Fetch', category: 'Experimental', requiresRestart: true, default: false, description: 'Enable web fetch behavior that bypasses LLM summarization.', showInDialog: true, }, dynamicModelConfiguration: { type: 'boolean', label: 'Dynamic Model Configuration', category: 'Experimental', requiresRestart: true, default: false, description: 'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.', showInDialog: false, }, gemmaModelRouter: { type: 'object', label: 'Gemma Model Router', category: 'Experimental', requiresRestart: true, default: {}, description: 'Enable Gemma model router (experimental).', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Gemma Model Router', category: 'Experimental', requiresRestart: true, default: false, description: 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', showInDialog: false, }, classifier: { type: 'object', label: 'Classifier', category: 'Experimental', requiresRestart: true, default: {}, description: 'Classifier configuration.', showInDialog: false, properties: { host: { type: 'string', label: 'Host', category: 'Experimental', requiresRestart: true, default: 'http://localhost:9379', description: 'The host of the classifier.', showInDialog: false, }, model: { type: 'string', label: 'Model', category: 'Experimental', requiresRestart: true, default: 'gemma3-1b-gpu-custom', description: 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', showInDialog: false, }, }, }, }, }, memoryManager: { type: 'boolean', label: 'Memory Manager Agent', category: 'Experimental', requiresRestart: true, default: false, description: 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', showInDialog: true, }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', category: 'Experimental', requiresRestart: false, default: false, description: 'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.', showInDialog: true, }, }, }, extensions: { type: 'object', label: 'Extensions', category: 'Extensions', requiresRestart: true, default: {}, description: 'Settings for extensions.', showInDialog: false, properties: { disabled: { type: 'array', label: 'Disabled Extensions', category: 'Extensions', requiresRestart: true, default: [] as string[], description: 'List of disabled extensions.', showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, workspacesWithMigrationNudge: { type: 'array', label: 'Workspaces with Migration Nudge', category: 'Extensions', requiresRestart: false, default: [] as string[], description: 'List of workspaces for which the migration nudge has been shown.', showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, }, }, skills: { type: 'object', label: 'Skills', category: 'Advanced', requiresRestart: true, default: {}, description: 'Settings for agent skills.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Agent Skills', category: 'Advanced', requiresRestart: true, default: true, description: 'Enable Agent Skills.', showInDialog: true, }, disabled: { type: 'array', label: 'Disabled Skills', category: 'Advanced', requiresRestart: true, default: [] as string[], description: 'List of disabled skills.', showInDialog: false, items: { type: 'string' }, mergeStrategy: MergeStrategy.UNION, }, }, }, hooksConfig: { type: 'object', label: 'HooksConfig', category: 'Advanced', requiresRestart: false, default: {}, description: 'Hook configurations for intercepting and customizing agent behavior.', showInDialog: false, properties: { enabled: { type: 'boolean', label: 'Enable Hooks', category: 'Advanced', requiresRestart: true, default: true, description: 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', showInDialog: true, }, disabled: { type: 'array', label: 'Disabled Hooks', category: 'Advanced', requiresRestart: false, default: [] as string[], description: 'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.', showInDialog: false, items: { type: 'string', description: 'Hook command name', }, mergeStrategy: MergeStrategy.UNION, }, notifications: { type: 'boolean', label: 'Hook Notifications', category: 'Advanced', requiresRestart: false, default: true, description: 'Show visual indicators when hooks are executing.', showInDialog: true, }, }, }, hooks: { type: 'object', label: 'Hook Events', category: 'Advanced', requiresRestart: false, default: {}, description: 'Event-specific hook configurations.', showInDialog: false, properties: { BeforeTool: { type: 'array', label: 'Before Tool Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, AfterTool: { type: 'array', label: 'After Tool Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute after tool execution. Can process results, log outputs, or trigger follow-up actions.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, BeforeAgent: { type: 'array', label: 'Before Agent Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute before agent loop starts. Can set up context or initialize resources.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, AfterAgent: { type: 'array', label: 'After Agent Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute after agent loop completes. Can perform cleanup or summarize results.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, Notification: { type: 'array', label: 'Notification Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute on notification events (errors, warnings, info). Can log or alert on specific conditions.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, SessionStart: { type: 'array', label: 'Session Start Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute when a session starts. Can initialize session-specific resources or state.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, SessionEnd: { type: 'array', label: 'Session End Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute when a session ends. Can perform cleanup or persist session data.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, PreCompress: { type: 'array', label: 'Pre-Compress Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute before chat history compression. Can back up or analyze conversation before compression.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, BeforeModel: { type: 'array', label: 'Before Model Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute before LLM requests. Can modify prompts, inject context, or control model parameters.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, AfterModel: { type: 'array', label: 'After Model Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute after LLM responses. Can process outputs, extract information, or log interactions.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, BeforeToolSelection: { type: 'array', label: 'Before Tool Selection Hooks', category: 'Advanced', requiresRestart: false, default: [], description: 'Hooks that execute before tool selection. Can filter or prioritize available tools dynamically.', showInDialog: false, ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, }, additionalProperties: { type: 'array', description: 'Custom hook event arrays that contain hook definitions for user-defined events', mergeStrategy: MergeStrategy.CONCAT, }, }, admin: { type: 'object', label: 'Admin', category: 'Admin', requiresRestart: false, default: {}, description: 'Settings configured remotely by enterprise admins.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, properties: { secureModeEnabled: { type: 'boolean', label: 'Secure Mode Enabled', category: 'Admin', requiresRestart: false, default: false, description: 'If true, disallows YOLO mode and "Always allow" options from being used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, extensions: { type: 'object', label: 'Extensions Settings', category: 'Admin', requiresRestart: false, default: {}, description: 'Extensions-specific admin settings.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, properties: { enabled: { type: 'boolean', label: 'Extensions Enabled', category: 'Admin', requiresRestart: false, default: true, description: 'If false, disallows extensions from being installed or used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, }, }, mcp: { type: 'object', label: 'MCP Settings', category: 'Admin', requiresRestart: false, default: {}, description: 'MCP-specific admin settings.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, properties: { enabled: { type: 'boolean', label: 'MCP Enabled', category: 'Admin', requiresRestart: false, default: true, description: 'If false, disallows MCP servers from being used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, config: { type: 'object', label: 'MCP Config', category: 'Admin', requiresRestart: false, default: {} as Record, description: 'Admin-configured MCP servers (allowlist).', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { type: 'object', ref: 'MCPServerConfig', }, }, requiredConfig: { type: 'object', label: 'Required MCP Config', category: 'Admin', requiresRestart: false, default: {} as Record, description: 'Admin-required MCP servers that are always injected.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { type: 'object', ref: 'RequiredMcpServerConfig', }, }, }, }, skills: { type: 'object', label: 'Skills Settings', category: 'Admin', requiresRestart: false, default: {}, description: 'Agent Skills-specific admin settings.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, properties: { enabled: { type: 'boolean', label: 'Skills Enabled', category: 'Admin', requiresRestart: false, default: true, description: 'If false, disallows agent skills from being used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, }, }, }, }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; export type SettingsJsonSchemaDefinition = Record; export const SETTINGS_SCHEMA_DEFINITIONS: Record< string, SettingsJsonSchemaDefinition > = { MCPServerConfig: { type: 'object', description: 'Definition of a Model Context Protocol (MCP) server configuration.', additionalProperties: false, properties: { command: { type: 'string', description: 'Executable invoked for stdio transport.', }, args: { type: 'array', description: 'Command-line arguments for the stdio transport command.', items: { type: 'string' }, }, env: { type: 'object', description: 'Environment variables to set for the server process.', additionalProperties: { type: 'string' }, }, cwd: { type: 'string', description: 'Working directory for the server process.', }, url: { type: 'string', description: 'URL for SSE or HTTP transport. Use with "type" field to specify transport type.', }, httpUrl: { type: 'string', description: 'Streaming HTTP transport URL.', }, headers: { type: 'object', description: 'Additional HTTP headers sent to the server.', additionalProperties: { type: 'string' }, }, tcp: { type: 'string', description: 'TCP address for websocket transport.', }, type: { type: 'string', description: 'Transport type. Use "stdio" for local command, "sse" for Server-Sent Events, or "http" for Streamable HTTP.', enum: ['stdio', 'sse', 'http'], }, timeout: { type: 'number', description: 'Timeout in milliseconds for MCP requests.', }, trust: { type: 'boolean', description: 'Marks the server as trusted. Trusted servers may gain additional capabilities.', }, description: { type: 'string', description: 'Human-readable description of the server.', }, includeTools: { type: 'array', description: 'Subset of tools that should be enabled for this server. When omitted all tools are enabled.', items: { type: 'string' }, }, excludeTools: { type: 'array', description: 'Tools that should be disabled for this server even if exposed.', items: { type: 'string' }, }, extension: { type: 'object', description: 'Metadata describing the Gemini CLI extension that owns this MCP server.', additionalProperties: { type: ['string', 'boolean', 'number'] }, }, oauth: { type: 'object', description: 'OAuth configuration for authenticating with the server.', additionalProperties: true, }, authProviderType: { type: 'string', description: 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', enum: Object.values(AuthProviderType), }, targetAudience: { type: 'string', description: 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', }, targetServiceAccount: { type: 'string', description: 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', }, }, }, RequiredMcpServerConfig: { type: 'object', description: 'Admin-required MCP server configuration (remote transports only).', additionalProperties: false, properties: { url: { type: 'string', description: 'URL for the required MCP server.', }, type: { type: 'string', description: 'Transport type for the required server.', enum: ['sse', 'http'], }, headers: { type: 'object', description: 'Additional HTTP headers sent to the server.', additionalProperties: { type: 'string' }, }, timeout: { type: 'number', description: 'Timeout in milliseconds for MCP requests.', }, trust: { type: 'boolean', description: 'Marks the server as trusted. Defaults to true for admin-required servers.', }, description: { type: 'string', description: 'Human-readable description of the server.', }, includeTools: { type: 'array', description: 'Subset of tools enabled for this server.', items: { type: 'string' }, }, excludeTools: { type: 'array', description: 'Tools disabled for this server.', items: { type: 'string' }, }, oauth: { type: 'object', description: 'OAuth configuration for authenticating with the server.', additionalProperties: true, }, authProviderType: { type: 'string', description: 'Authentication provider used for acquiring credentials.', enum: Object.values(AuthProviderType), }, targetAudience: { type: 'string', description: 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', }, targetServiceAccount: { type: 'string', description: 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', }, }, }, TelemetrySettings: { type: 'object', description: 'Telemetry configuration for Gemini CLI.', additionalProperties: false, properties: { enabled: { type: 'boolean', description: 'Enables telemetry emission.', }, target: { type: 'string', description: 'Telemetry destination (for example `stderr`, `stdout`, or `otlp`).', }, otlpEndpoint: { type: 'string', description: 'Endpoint for OTLP exporters.', }, otlpProtocol: { type: 'string', description: 'Protocol for OTLP exporters.', enum: ['grpc', 'http'], }, logPrompts: { type: 'boolean', description: 'Whether prompts are logged in telemetry payloads.', }, outfile: { type: 'string', description: 'File path for writing telemetry output.', }, useCollector: { type: 'boolean', description: 'Whether to forward telemetry to an OTLP collector.', }, useCliAuth: { type: 'boolean', description: 'Whether to use CLI authentication for telemetry (only for in-process exporters).', }, }, }, BugCommandSettings: { type: 'object', description: 'Configuration for the bug report helper command.', additionalProperties: false, properties: { urlTemplate: { type: 'string', description: 'Template used to open a bug report URL. Variables in the template are populated at runtime.', }, }, required: ['urlTemplate'], }, SummarizeToolOutputSettings: { type: 'object', description: 'Controls summarization behavior for individual tools. All properties are optional.', additionalProperties: false, properties: { tokenBudget: { type: 'number', description: 'Maximum number of tokens used when summarizing tool output.', }, }, }, AgentOverride: { type: 'object', description: 'Override settings for a specific agent.', additionalProperties: false, properties: { modelConfig: { type: 'object', additionalProperties: true, }, runConfig: { type: 'object', description: 'Run configuration for an agent.', additionalProperties: false, properties: { maxTimeMinutes: { type: 'number', description: 'The maximum execution time for the agent in minutes.', }, maxTurns: { type: 'number', description: 'The maximum number of conversational turns.', }, }, }, enabled: { type: 'boolean', description: 'Whether to enable the agent.', }, }, }, CustomTheme: { type: 'object', description: 'Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.', additionalProperties: false, properties: { type: { type: 'string', enum: ['custom'], default: 'custom', }, name: { type: 'string', description: 'Theme display name.', }, text: { type: 'object', additionalProperties: false, properties: { primary: { type: 'string' }, secondary: { type: 'string' }, link: { type: 'string' }, accent: { type: 'string' }, }, }, background: { type: 'object', additionalProperties: false, properties: { primary: { type: 'string' }, diff: { type: 'object', additionalProperties: false, properties: { added: { type: 'string' }, removed: { type: 'string' }, }, }, }, }, border: { type: 'object', additionalProperties: false, properties: { default: { type: 'string' }, focused: { type: 'string' }, }, }, ui: { type: 'object', additionalProperties: false, properties: { comment: { type: 'string' }, symbol: { type: 'string' }, gradient: { type: 'array', items: { type: 'string' }, }, }, }, status: { type: 'object', additionalProperties: false, properties: { error: { type: 'string' }, success: { type: 'string' }, warning: { type: 'string' }, }, }, Background: { type: 'string' }, Foreground: { type: 'string' }, LightBlue: { type: 'string' }, AccentBlue: { type: 'string' }, AccentPurple: { type: 'string' }, AccentCyan: { type: 'string' }, AccentGreen: { type: 'string' }, AccentYellow: { type: 'string' }, AccentRed: { type: 'string' }, DiffAdded: { type: 'string' }, DiffRemoved: { type: 'string' }, Comment: { type: 'string' }, Gray: { type: 'string' }, DarkGray: { type: 'string' }, GradientColors: { type: 'array', items: { type: 'string' }, }, }, required: ['type', 'name'], }, StringOrStringArray: { description: 'Accepts either a single string or an array of strings.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], }, BooleanOrStringOrObject: { description: 'Accepts either a boolean flag, a string command name, or a configuration object.', anyOf: [ { type: 'boolean' }, { type: 'string' }, { type: 'object', description: 'Sandbox configuration object.', additionalProperties: false, properties: { enabled: { type: 'boolean', description: 'Enables or disables the sandbox.', }, command: { type: 'string', description: 'The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).', enum: ['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'], }, image: { type: 'string', description: 'The sandbox image to use.', }, allowedPaths: { type: 'array', description: 'A list of absolute host paths that should be accessible within the sandbox.', items: { type: 'string' }, }, networkAccess: { type: 'boolean', description: 'Whether the sandbox should have internet access.', }, }, }, ], }, HookDefinitionArray: { type: 'array', description: 'Array of hook definition objects for a specific event.', items: { type: 'object', description: 'Hook definition specifying matcher pattern and hook configurations.', properties: { matcher: { type: 'string', description: 'Pattern to match against the event context (tool name, notification type, etc.). Supports exact match, regex (/pattern/), and wildcards (*).', }, hooks: { type: 'array', description: 'Hooks to execute when the matcher matches.', items: { type: 'object', description: 'Individual hook configuration.', properties: { name: { type: 'string', description: 'Unique identifier for the hook.', }, type: { type: 'string', description: 'Type of hook (currently only "command" supported).', }, command: { type: 'string', description: 'Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout.', }, description: { type: 'string', description: 'A description of the hook.', }, timeout: { type: 'number', description: 'Timeout in milliseconds for hook execution.', }, }, }, }, }, }, }, ModelDefinition: { type: 'object', description: 'Model metadata registry entry.', properties: { displayName: { type: 'string' }, tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] }, family: { type: 'string' }, isPreview: { type: 'boolean' }, isVisible: { type: 'boolean' }, dialogDescription: { type: 'string' }, features: { type: 'object', properties: { thinking: { type: 'boolean' }, multimodalToolUse: { type: 'boolean' }, }, }, }, }, ModelResolution: { type: 'object', description: 'Model resolution rule.', properties: { default: { type: 'string' }, contexts: { type: 'array', items: { type: 'object', properties: { condition: { type: 'object', properties: { useGemini3_1: { type: 'boolean' }, useCustomTools: { type: 'boolean' }, hasAccessToPreview: { type: 'boolean' }, requestedModels: { type: 'array', items: { type: 'string' }, }, }, }, target: { type: 'string' }, }, }, }, }, }, ModelPolicy: { type: 'object', description: 'Defines the policy for a single model in the availability chain.', properties: { model: { type: 'string' }, isLastResort: { type: 'boolean' }, actions: { type: 'object', properties: { terminal: { type: 'string', enum: ['silent', 'prompt'] }, transient: { type: 'string', enum: ['silent', 'prompt'] }, not_found: { type: 'string', enum: ['silent', 'prompt'] }, unknown: { type: 'string', enum: ['silent', 'prompt'] }, }, }, stateTransitions: { type: 'object', properties: { terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, }, }, }, required: ['model'], }, }; export function getSettingsSchema(): SettingsSchemaType { return SETTINGS_SCHEMA; } type InferSettings = { -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } ? InferSettings : T[K]['type'] extends 'enum' ? T[K]['options'] extends readonly SettingEnumOption[] ? T[K]['options'][number]['value'] : T[K]['default'] : T[K]['default'] extends boolean ? boolean : T[K]['default'] extends string ? string : T[K]['default'] extends ReadonlyArray ? U[] : T[K]['default']; }; type InferMergedSettings = { -readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema } ? InferMergedSettings : T[K]['type'] extends 'enum' ? T[K]['options'] extends readonly SettingEnumOption[] ? T[K]['options'][number]['value'] : T[K]['default'] : T[K]['default'] extends boolean ? boolean : T[K]['default'] extends string ? string : T[K]['default'] extends ReadonlyArray ? U[] : T[K]['default']; }; export type Settings = InferSettings; export type MergedSettings = InferMergedSettings; ================================================ FILE: packages/cli/src/config/settings_repro.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// // Mock 'os' first. import * as osActual from 'node:os'; vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, homedir: vi.fn(() => '/mock/home/user'), platform: vi.fn(() => 'linux'), }; }); // Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants. vi.mock('./settings.js', async (importActual) => { const originalModule = await importActual(); return { __esModule: true, ...originalModule, }; }); // Mock trustedFolders vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi .fn() .mockReturnValue({ isTrusted: true, source: 'file' }), })); import { describe, it, expect, vi, beforeEach, afterEach, type Mocked, type Mock, } from 'vitest'; import * as fs from 'node:fs'; import stripJsonComments from 'strip-json-comments'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { loadSettings, USER_SETTINGS_PATH } from './settings.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { ...actualFs, existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), renameSync: vi.fn(), realpathSync: (p: string) => p, }; }); vi.mock('./extension.js'); const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: mockCoreEvents, }; }); vi.mock('../utils/commentJson.js', () => ({ updateSettingsFilePreservingFormat: vi.fn(), })); vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); describe('Settings Repro', () => { let mockFsExistsSync: Mocked; let mockStripJsonComments: Mocked; let mockFsMkdirSync: Mocked; beforeEach(() => { vi.resetAllMocks(); mockFsExistsSync = vi.mocked(fs.existsSync); mockFsMkdirSync = vi.mocked(fs.mkdirSync); mockStripJsonComments = vi.mocked(stripJsonComments); vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); (mockStripJsonComments as unknown as Mock).mockImplementation( (jsonString: string) => jsonString, ); (mockFsExistsSync as Mock).mockReturnValue(false); (fs.readFileSync as Mock).mockReturnValue('{}'); (mockFsMkdirSync as Mock).mockImplementation(() => undefined); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); }); afterEach(() => { vi.restoreAllMocks(); }); it('should handle the problematic settings.json without crashing', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); const problemSettingsContent = { accessibility: { screenReader: true, }, ide: { enabled: false, hasSeenNudge: true, }, general: { debugKeystrokeLogging: false, preferredEditor: 'vim', vimMode: false, }, security: { auth: { selectedType: 'gemini-api-key', }, folderTrust: { enabled: true, }, }, tools: { useRipgrep: true, shell: { showColor: true, enableInteractiveShell: true, }, }, experimental: { useModelRouter: false, enableSubagents: false, }, agents: { overrides: { codebase_investigator: { enabled: true, }, }, }, ui: { accessibility: { screenReader: false, }, showMemoryUsage: true, showStatusInTitle: true, showCitations: true, useInkScrolling: true, footer: { hideContextPercentage: false, hideModelInfo: false, }, }, useWriteTodos: true, output: { format: 'text', }, model: { compressionThreshold: 0.8, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { if (p === USER_SETTINGS_PATH) return JSON.stringify(problemSettingsContent); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); // If it doesn't throw, check if it merged correctly. // The model.compressionThreshold should be present. // And model.name should probably be undefined or default, but certainly NOT { compressionThreshold: 0.8 } expect(settings.merged.model?.compressionThreshold).toBe(0.8); expect(typeof settings.merged.model?.name).not.toBe('object'); }); }); ================================================ FILE: packages/cli/src/config/settings_validation_warning.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import * as fs from 'node:fs'; const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), emitConsoleLog: vi.fn(), emitOutput: vi.fn(), emitModelChanged: vi.fn(), drainBacklogs: vi.fn(), })); const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn().mockReturnValue({ isTrusted: true, source: 'file' }), ); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: mockCoreEvents, homedir: () => '/mock/home/user', Storage: class extends actual.Storage { static override getGlobalSettingsPath = () => '/mock/home/user/.gemini/settings.json'; override getWorkspaceSettingsPath = () => '/mock/workspace/.gemini/settings.json'; static override getGlobalGeminiDir = () => '/mock/home/user/.gemini'; }, }; }); vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: mockIsWorkspaceTrusted, loadTrustedFolders: vi.fn().mockReturnValue({ isPathTrusted: vi.fn().mockReturnValue(true), user: { config: {} }, errors: [], }), isFolderTrustEnabled: vi.fn().mockReturnValue(false), TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER', TRUST_PARENT: 'TRUST_PARENT', DO_NOT_TRUST: 'DO_NOT_TRUST', }, })); vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: () => '/mock/home/user', platform: () => 'linux', totalmem: () => 16 * 1024 * 1024 * 1024, }; }); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { ...actualFs, existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), renameSync: vi.fn(), realpathSync: (p: string) => p, }; }); // Import loadSettings after all mocks are defined import { loadSettings, USER_SETTINGS_PATH, type LoadedSettings, resetSettingsCacheForTesting, } from './settings.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; describe('Settings Validation Warning', () => { beforeEach(() => { vi.clearAllMocks(); resetSettingsCacheForTesting(); (fs.readFileSync as Mock).mockReturnValue('{}'); (fs.existsSync as Mock).mockReturnValue(false); }); it('should emit a warning and NOT throw when settings are invalid', () => { (fs.existsSync as Mock).mockImplementation( (p: string) => p === USER_SETTINGS_PATH, ); const invalidSettingsContent = { ui: { customThemes: { terafox: { name: 'terafox', type: 'custom', DiffModified: '#ffffff', // Invalid key }, }, }, }; (fs.readFileSync as Mock).mockImplementation((p: string) => { if (p === USER_SETTINGS_PATH) return JSON.stringify(invalidSettingsContent); return '{}'; }); // Should NOT throw let settings: LoadedSettings | undefined; expect(() => { settings = loadSettings(MOCK_WORKSPACE_DIR); }).not.toThrow(); // Should have recorded a warning in the settings object expect( settings?.errors.some((e) => e.message.includes("Unrecognized key(s) in object: 'DiffModified'"), ), ).toBe(true); }); it('should throw a fatal error when settings file is not a valid JSON object', () => { (fs.existsSync as Mock).mockImplementation( (p: string) => p === USER_SETTINGS_PATH, ); (fs.readFileSync as Mock).mockImplementation((p: string) => { if (p === USER_SETTINGS_PATH) return '[]'; return '{}'; }); expect(() => { loadSettings(MOCK_WORKSPACE_DIR); }).toThrow(); }); it('should throw a fatal error when settings file contains invalid JSON', () => { (fs.existsSync as Mock).mockImplementation( (p: string) => p === USER_SETTINGS_PATH, ); (fs.readFileSync as Mock).mockImplementation((p: string) => { if (p === USER_SETTINGS_PATH) return '{ "invalid": "json", }'; // Trailing comma is invalid in standard JSON return '{}'; }); expect(() => { loadSettings(MOCK_WORKSPACE_DIR); }).toThrow(); }); }); ================================================ FILE: packages/cli/src/config/trustedFolders.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { FatalConfigError, ideContextStore, coreEvents, } from '@google/gemini-cli-core'; import { loadTrustedFolders, TrustLevel, isWorkspaceTrusted, resetTrustedFoldersForTesting, } from './trustedFolders.js'; import { loadEnvironment, type Settings } from './settings.js'; import { createMockSettings } from '../test-utils/settings.js'; // We explicitly do NOT mock 'fs' or 'proper-lockfile' here to ensure // we are testing the actual behavior on the real file system. vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: () => '/mock/home/user', isHeadlessMode: vi.fn(() => false), coreEvents: { emitFeedback: vi.fn(), }, }; }); describe('Trusted Folders', () => { let tempDir: string; let trustedFoldersPath: string; beforeEach(() => { // Create a temporary directory for each test tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); trustedFoldersPath = path.join(tempDir, 'trustedFolders.json'); // Set the environment variable to point to the temp file vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath); // Reset the internal state resetTrustedFoldersForTesting(); vi.clearAllMocks(); }); afterEach(() => { // Clean up the temporary directory fs.rmSync(tempDir, { recursive: true, force: true }); vi.unstubAllEnvs(); }); describe('Locking & Concurrency', () => { it('setValue should handle concurrent calls correctly using real lockfile', async () => { // Initialize the file fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); const loadedFolders = loadTrustedFolders(); // Start two concurrent calls // These will race to acquire the lock on the real file system const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER); const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER); await Promise.all([p1, p2]); // Verify final state in the file const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); const config = JSON.parse(content); expect(config).toEqual({ '/path1': TrustLevel.TRUST_FOLDER, '/path2': TrustLevel.TRUST_FOLDER, }); }); }); describe('Loading & Parsing', () => { it('should load empty rules if no files exist', () => { const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([]); expect(errors).toEqual([]); }); it('should load rules from the configuration file', () => { const config = { '/user/folder': TrustLevel.TRUST_FOLDER, }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([ { path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER }, ]); expect(errors).toEqual([]); }); it('should handle JSON parsing errors gracefully', () => { fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([]); expect(errors.length).toBe(1); expect(errors[0].path).toBe(trustedFoldersPath); expect(errors[0].message).toContain('Unexpected token'); }); it('should handle non-object JSON gracefully', () => { fs.writeFileSync(trustedFoldersPath, 'null', 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([]); expect(errors.length).toBe(1); expect(errors[0].message).toContain('not a valid JSON object'); }); it('should handle invalid trust levels gracefully', () => { const config = { '/path': 'INVALID_LEVEL', }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([]); expect(errors.length).toBe(1); expect(errors[0].message).toContain( 'Invalid trust level "INVALID_LEVEL"', ); }); it('should support JSON with comments', () => { const content = ` { // This is a comment "/path": "TRUST_FOLDER" } `; fs.writeFileSync(trustedFoldersPath, content, 'utf-8'); const { rules, errors } = loadTrustedFolders(); expect(rules).toEqual([ { path: '/path', trustLevel: TrustLevel.TRUST_FOLDER }, ]); expect(errors).toEqual([]); }); }); describe('isPathTrusted', () => { function setup(config: Record) { fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); return loadTrustedFolders(); } it('provides a method to determine if a path is trusted', () => { const folders = setup({ './myfolder': TrustLevel.TRUST_FOLDER, '/trustedparent/trustme': TrustLevel.TRUST_PARENT, '/user/folder': TrustLevel.TRUST_FOLDER, '/secret': TrustLevel.DO_NOT_TRUST, '/secret/publickeys': TrustLevel.TRUST_FOLDER, }); // We need to resolve relative paths for comparison since the implementation uses realpath const resolvedMyFolder = path.resolve('./myfolder'); expect(folders.isPathTrusted('/secret')).toBe(false); expect(folders.isPathTrusted('/user/folder')).toBe(true); expect(folders.isPathTrusted('/secret/publickeys/public.pem')).toBe(true); expect(folders.isPathTrusted('/user/folder/harhar')).toBe(true); expect( folders.isPathTrusted(path.join(resolvedMyFolder, 'somefile.jpg')), ).toBe(true); expect(folders.isPathTrusted('/trustedparent/someotherfolder')).toBe( true, ); expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true); // No explicit rule covers this file expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false); expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false); expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined); }); it('prioritizes the longest matching path (precedence)', () => { const folders = setup({ '/a': TrustLevel.TRUST_FOLDER, '/a/b': TrustLevel.DO_NOT_TRUST, '/a/b/c': TrustLevel.TRUST_FOLDER, '/parent/trustme': TrustLevel.TRUST_PARENT, '/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST, }); expect(folders.isPathTrusted('/a/b/c/d')).toBe(true); expect(folders.isPathTrusted('/a/b/x')).toBe(false); expect(folders.isPathTrusted('/a/x')).toBe(true); expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe( false, ); expect(folders.isPathTrusted('/parent/other')).toBe(true); }); }); describe('setValue', () => { it('should update the user config and save it atomically', async () => { fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); const loadedFolders = loadTrustedFolders(); await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); expect(loadedFolders.user.config['/new/path']).toBe( TrustLevel.TRUST_FOLDER, ); const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); const config = JSON.parse(content); expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER); }); it('should throw FatalConfigError if there were load errors', async () => { fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); const loadedFolders = loadTrustedFolders(); expect(loadedFolders.errors.length).toBe(1); await expect( loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER), ).rejects.toThrow(FatalConfigError); }); it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => { // Initialize with valid JSON fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); const loadedFolders = loadTrustedFolders(); // Corrupt the file after initial load fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'error', expect.stringContaining('may be corrupted'), expect.any(Error), ); // Should have overwritten the corrupted file with new valid config const content = fs.readFileSync(trustedFoldersPath, 'utf-8'); const config = JSON.parse(content); expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER }); }); }); describe('isWorkspaceTrusted Integration', () => { const mockSettings: Settings = { security: { folderTrust: { enabled: true, }, }, }; it('should return true for a directly trusted folder', () => { const config = { '/projectA': TrustLevel.TRUST_FOLDER }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ isTrusted: true, source: 'file', }); }); it('should return true for a child of a trusted folder', () => { const config = { '/projectA': TrustLevel.TRUST_FOLDER }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/projectA/src')).toEqual({ isTrusted: true, source: 'file', }); }); it('should return true for a child of a trusted parent folder', () => { const config = { '/projectB/somefile.txt': TrustLevel.TRUST_PARENT }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/projectB')).toEqual({ isTrusted: true, source: 'file', }); }); it('should return false for a directly untrusted folder', () => { const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/untrusted')).toEqual({ isTrusted: false, source: 'file', }); }); it('should return false for a child of an untrusted folder', () => { const config = { '/untrusted': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/untrusted/src').isTrusted).toBe( false, ); }); it('should return undefined when no rules match', () => { fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8'); expect( isWorkspaceTrusted(mockSettings, '/other').isTrusted, ).toBeUndefined(); }); it('should prioritize specific distrust over parent trust', () => { const config = { '/projectA': TrustLevel.TRUST_FOLDER, '/projectA/untrusted': TrustLevel.DO_NOT_TRUST, }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/projectA/untrusted')).toEqual({ isTrusted: false, source: 'file', }); }); it('should use workspaceDir instead of process.cwd() when provided', () => { const config = { '/projectA': TrustLevel.TRUST_FOLDER, '/untrusted': TrustLevel.DO_NOT_TRUST, }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); vi.spyOn(process, 'cwd').mockImplementation(() => '/untrusted'); // process.cwd() is untrusted, but workspaceDir is trusted expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ isTrusted: true, source: 'file', }); }); it('should handle path normalization', () => { const config = { '/home/user/projectA': TrustLevel.TRUST_FOLDER }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect( isWorkspaceTrusted(mockSettings, '/home/user/../user/projectA'), ).toEqual({ isTrusted: true, source: 'file', }); }); it('should prioritize IDE override over file config', () => { const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); ideContextStore.set({ workspaceState: { isTrusted: true } }); try { expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ isTrusted: true, source: 'ide', }); } finally { ideContextStore.clear(); } }); it('should return false when IDE override is false', () => { const config = { '/projectA': TrustLevel.TRUST_FOLDER }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); ideContextStore.set({ workspaceState: { isTrusted: false } }); try { expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({ isTrusted: false, source: 'ide', }); } finally { ideContextStore.clear(); } }); it('should throw FatalConfigError when the config file is invalid', () => { fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8'); expect(() => isWorkspaceTrusted(mockSettings, '/any')).toThrow( FatalConfigError, ); }); it('should always return true if folderTrust setting is disabled', () => { const disabledSettings: Settings = { security: { folderTrust: { enabled: false } }, }; expect(isWorkspaceTrusted(disabledSettings, '/any')).toEqual({ isTrusted: true, source: undefined, }); }); }); describe('isWorkspaceTrusted headless mode', () => { const mockSettings: Settings = { security: { folderTrust: { enabled: true, }, }, }; it('should return true when isHeadlessMode is true, ignoring config', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); expect(isWorkspaceTrusted(mockSettings)).toEqual({ isTrusted: true, source: undefined, }); }); it('should fall back to config when isHeadlessMode is false', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false); const config = { '/projectA': TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe( false, ); }); it('should return true for isPathTrusted when isHeadlessMode is true', async () => { const geminiCore = await import('@google/gemini-cli-core'); vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true); const folders = loadTrustedFolders(); expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true); }); }); describe('Trusted Folders Caching', () => { it('should cache the loaded folders object', () => { // First call should load and cache const folders1 = loadTrustedFolders(); // Second call should return the same instance from cache const folders2 = loadTrustedFolders(); expect(folders1).toBe(folders2); // Resetting should clear the cache resetTrustedFoldersForTesting(); // Third call should return a new instance const folders3 = loadTrustedFolders(); expect(folders3).not.toBe(folders1); }); }); describe('invalid trust levels', () => { it('should create a comprehensive error message for invalid trust level', () => { const config = { '/user/folder': 'INVALID_TRUST_LEVEL' }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); const { errors } = loadTrustedFolders(); const possibleValues = Object.values(TrustLevel).join(', '); expect(errors.length).toBe(1); expect(errors[0].message).toBe( `Invalid trust level "INVALID_TRUST_LEVEL" for path "/user/folder". Possible values are: ${possibleValues}.`, ); }); }); const itif = (condition: boolean) => (condition ? it : it.skip); describe('Symlinks Support', () => { const mockSettings: Settings = { security: { folderTrust: { enabled: true } }, }; // TODO: issue 19387 - Enable symlink tests on Windows itif(process.platform !== 'win32')( 'should trust a folder if the rule matches the realpath', () => { // Create a real directory and a symlink const realDir = path.join(tempDir, 'real'); const symlinkDir = path.join(tempDir, 'symlink'); fs.mkdirSync(realDir); fs.symlinkSync(realDir, symlinkDir, 'dir'); // Rule uses realpath const config = { [realDir]: TrustLevel.TRUST_FOLDER }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); // Check against symlink path expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe( true, ); }, ); }); describe('Verification: Auth and Trust Interaction', () => { it('should verify loadEnvironment returns early when untrusted', () => { const untrustedDir = path.join(tempDir, 'untrusted'); fs.mkdirSync(untrustedDir); const config = { [untrustedDir]: TrustLevel.DO_NOT_TRUST }; fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); const envPath = path.join(untrustedDir, '.env'); fs.writeFileSync(envPath, 'GEMINI_API_KEY=secret', 'utf-8'); vi.stubEnv('GEMINI_API_KEY', ''); const settings = createMockSettings({ security: { folderTrust: { enabled: true } }, }); loadEnvironment(settings.merged, untrustedDir); expect(process.env['GEMINI_API_KEY']).toBe(''); vi.unstubAllEnvs(); }); }); }); ================================================ FILE: packages/cli/src/config/trustedFolders.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; import { lock } from 'proper-lockfile'; import { FatalConfigError, getErrorMessage, isWithinRoot, ideContextStore, GEMINI_DIR, homedir, isHeadlessMode, coreEvents, type HeadlessModeOptions, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; const { promises: fsPromises } = fs; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export function getUserSettingsDir(): string { return path.join(homedir(), GEMINI_DIR); } export function getTrustedFoldersPath(): string { if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; } return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME); } export enum TrustLevel { TRUST_FOLDER = 'TRUST_FOLDER', TRUST_PARENT = 'TRUST_PARENT', DO_NOT_TRUST = 'DO_NOT_TRUST', } export function isTrustLevel( value: string | number | boolean | object | null | undefined, ): value is TrustLevel { return ( typeof value === 'string' && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion Object.values(TrustLevel).includes(value as TrustLevel) ); } export interface TrustRule { path: string; trustLevel: TrustLevel; } export interface TrustedFoldersError { message: string; path: string; } export interface TrustedFoldersFile { config: Record; path: string; } export interface TrustResult { isTrusted: boolean | undefined; source: 'ide' | 'file' | undefined; } const realPathCache = new Map(); /** * Parses the trusted folders JSON content, stripping comments. */ function parseTrustedFoldersJson(content: string): unknown { return JSON.parse(stripJsonComments(content)); } /** * FOR TESTING PURPOSES ONLY. * Clears the real path cache. */ export function clearRealPathCacheForTesting(): void { realPathCache.clear(); } function getRealPath(location: string): string { let realPath = realPathCache.get(location); if (realPath !== undefined) { return realPath; } try { realPath = fs.existsSync(location) ? fs.realpathSync(location) : location; } catch { realPath = location; } realPathCache.set(location, realPath); return realPath; } export class LoadedTrustedFolders { constructor( readonly user: TrustedFoldersFile, readonly errors: TrustedFoldersError[], ) {} get rules(): TrustRule[] { return Object.entries(this.user.config).map(([path, trustLevel]) => ({ path, trustLevel, })); } /** * Returns true or false if the path should be "trusted". This function * should only be invoked when the folder trust setting is active. * * @param location path * @returns */ isPathTrusted( location: string, config?: Record, headlessOptions?: HeadlessModeOptions, ): boolean | undefined { if (isHeadlessMode(headlessOptions)) { return true; } const configToUse = config ?? this.user.config; // Resolve location to its realpath for canonical comparison const realLocation = getRealPath(location); let longestMatchLen = -1; let longestMatchTrust: TrustLevel | undefined = undefined; for (const [rulePath, trustLevel] of Object.entries(configToUse)) { const effectivePath = trustLevel === TrustLevel.TRUST_PARENT ? path.dirname(rulePath) : rulePath; // Resolve effectivePath to its realpath for canonical comparison const realEffectivePath = getRealPath(effectivePath); if (isWithinRoot(realLocation, realEffectivePath)) { if (rulePath.length > longestMatchLen) { longestMatchLen = rulePath.length; longestMatchTrust = trustLevel; } } } if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false; if ( longestMatchTrust === TrustLevel.TRUST_FOLDER || longestMatchTrust === TrustLevel.TRUST_PARENT ) return true; return undefined; } async setValue(folderPath: string, trustLevel: TrustLevel): Promise { if (this.errors.length > 0) { const errorMessages = this.errors.map( (error) => `Error in ${error.path}: ${error.message}`, ); throw new FatalConfigError( `Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`, ); } const dirPath = path.dirname(this.user.path); if (!fs.existsSync(dirPath)) { await fsPromises.mkdir(dirPath, { recursive: true }); } // lockfile requires the file to exist if (!fs.existsSync(this.user.path)) { await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), { mode: 0o600, }); } const release = await lock(this.user.path, { retries: { retries: 10, minTimeout: 100, }, }); try { // Re-read the file to handle concurrent updates const content = await fsPromises.readFile(this.user.path, 'utf-8'); let config: Record; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion config = parseTrustedFoldersJson(content) as Record; } catch (error) { coreEvents.emitFeedback( 'error', `Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`, error, ); config = {}; } const originalTrustLevel = config[folderPath]; config[folderPath] = trustLevel; this.user.config[folderPath] = trustLevel; try { saveTrustedFolders({ ...this.user, config }); } catch (e) { // Revert the in-memory change if the save failed. if (originalTrustLevel === undefined) { delete this.user.config[folderPath]; } else { this.user.config[folderPath] = originalTrustLevel; } throw e; } } finally { await release(); } } } let loadedTrustedFolders: LoadedTrustedFolders | undefined; /** * FOR TESTING PURPOSES ONLY. * Resets the in-memory cache of the trusted folders configuration. */ export function resetTrustedFoldersForTesting(): void { loadedTrustedFolders = undefined; clearRealPathCacheForTesting(); } export function loadTrustedFolders(): LoadedTrustedFolders { if (loadedTrustedFolders) { return loadedTrustedFolders; } const errors: TrustedFoldersError[] = []; const userConfig: Record = {}; const userPath = getTrustedFoldersPath(); try { if (fs.existsSync(userPath)) { const content = fs.readFileSync(userPath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const parsed = parseTrustedFoldersJson(content) as Record; if ( typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) ) { errors.push({ message: 'Trusted folders file is not a valid JSON object.', path: userPath, }); } else { for (const [path, trustLevel] of Object.entries(parsed)) { if (isTrustLevel(trustLevel)) { userConfig[path] = trustLevel; } else { const possibleValues = Object.values(TrustLevel).join(', '); errors.push({ message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`, path: userPath, }); } } } } } catch (error) { errors.push({ message: getErrorMessage(error), path: userPath, }); } loadedTrustedFolders = new LoadedTrustedFolders( { path: userPath, config: userConfig }, errors, ); return loadedTrustedFolders; } export function saveTrustedFolders( trustedFoldersFile: TrustedFoldersFile, ): void { // Ensure the directory exists const dirPath = path.dirname(trustedFoldersFile.path); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } const content = JSON.stringify(trustedFoldersFile.config, null, 2); const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`; try { fs.writeFileSync(tempPath, content, { encoding: 'utf-8', mode: 0o600, }); fs.renameSync(tempPath, trustedFoldersFile.path); } catch (error) { // Clean up temp file if it was created but rename failed if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch { // Ignore cleanup errors } } throw error; } } /** Is folder trust feature enabled per the current applied settings */ export function isFolderTrustEnabled(settings: Settings): boolean { const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; return folderTrustSetting; } function getWorkspaceTrustFromLocalConfig( workspaceDir: string, trustConfig?: Record, headlessOptions?: HeadlessModeOptions, ): TrustResult { const folders = loadTrustedFolders(); const configToUse = trustConfig ?? folders.user.config; if (folders.errors.length > 0) { const errorMessages = folders.errors.map( (error) => `Error in ${error.path}: ${error.message}`, ); throw new FatalConfigError( `${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`, ); } const isTrusted = folders.isPathTrusted( workspaceDir, configToUse, headlessOptions, ); return { isTrusted, source: isTrusted !== undefined ? 'file' : undefined, }; } export function isWorkspaceTrusted( settings: Settings, workspaceDir: string = process.cwd(), trustConfig?: Record, headlessOptions?: HeadlessModeOptions, ): TrustResult { if (isHeadlessMode(headlessOptions)) { return { isTrusted: true, source: undefined }; } if (!isFolderTrustEnabled(settings)) { return { isTrusted: true, source: undefined }; } const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted; if (ideTrust !== undefined) { return { isTrusted: ideTrust, source: 'ide' }; } // Fall back to the local user configuration return getWorkspaceTrustFromLocalConfig( workspaceDir, trustConfig, headlessOptions, ); } ================================================ FILE: packages/cli/src/config/workspace-policy-cli.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as path from 'node:path'; import { loadCliConfig, type CliArgs } from './config.js'; import { createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import * as Policy from './policy.js'; // Mock dependencies vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(), })); const mockCheckIntegrity = vi.fn(); const mockAcceptIntegrity = vi.fn(); vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual( '@google/gemini-cli-core', ); return { ...actual, loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [], }), createPolicyEngineConfig: vi.fn().mockResolvedValue({ rules: [], checkers: [], }), getVersion: vi.fn().mockResolvedValue('test-version'), PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ checkIntegrity: mockCheckIntegrity, acceptIntegrity: mockAcceptIntegrity, })), IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, debugLogger: { warn: vi.fn(), error: vi.fn(), }, isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive }; }); describe('Workspace-Level Policy CLI Integration', () => { const MOCK_CWD = process.cwd(); beforeEach(() => { vi.clearAllMocks(); Policy.setDisableWorkspacePolicies(false); // Default to MATCH for existing tests mockCheckIntegrity.mockResolvedValue({ status: 'match', hash: 'test-hash', fileCount: 1, }); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); }); it('should have getWorkspacePoliciesDir on Storage class', () => { const storage = new ServerConfig.Storage(MOCK_CWD); expect(storage.getWorkspacePoliciesDir).toBeDefined(); expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); }); it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); const settings = createTestMergedSettings(); const argv = { query: 'test' } as unknown as CliArgs; await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), ), }), expect.anything(), ); }); it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, source: 'file', }); const settings = createTestMergedSettings(); const argv = { query: 'test' } as unknown as CliArgs; await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), expect.anything(), ); }); it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); mockCheckIntegrity.mockResolvedValue({ status: 'new', hash: 'hash', fileCount: 0, }); const settings = createTestMergedSettings(); const argv = { query: 'test' } as unknown as CliArgs; await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), expect.anything(), ); }); it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); mockCheckIntegrity.mockResolvedValue({ status: 'mismatch', hash: 'new-hash', fileCount: 1, }); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive const settings = createTestMergedSettings(); const argv = { prompt: 'do something' } as unknown as CliArgs; await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); expect(mockAcceptIntegrity).toHaveBeenCalledWith( 'workspace', MOCK_CWD, 'new-hash', ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), ), }), expect.anything(), ); }); it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); mockCheckIntegrity.mockResolvedValue({ status: 'mismatch', hash: 'new-hash', fileCount: 1, }); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { query: 'test', promptInteractive: 'test', } as unknown as CliArgs; const config = await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD, }); expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); expect(mockAcceptIntegrity).toHaveBeenCalledWith( 'workspace', MOCK_CWD, 'new-hash', ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), ), }), expect.anything(), ); }); it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); mockCheckIntegrity.mockResolvedValue({ status: 'new', hash: 'new-hash', fileCount: 5, }); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { query: 'test' } as unknown as CliArgs; const config = await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD, }); expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); expect(mockAcceptIntegrity).toHaveBeenCalledWith( 'workspace', MOCK_CWD, 'new-hash', ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), ), }), expect.anything(), ); }); it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => { // Monkey patch autoAcceptWorkspacePolicies using setter const originalValue = Policy.autoAcceptWorkspacePolicies; Policy.setAutoAcceptWorkspacePolicies(false); try { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', }); mockCheckIntegrity.mockResolvedValue({ status: 'mismatch', hash: 'new-hash', fileCount: 1, }); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { query: 'test', promptInteractive: 'test', } as unknown as CliArgs; const config = await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD, }); expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ scope: 'workspace', identifier: MOCK_CWD, policyDir: expect.stringContaining(path.join('.gemini', 'policies')), newHash: 'new-hash', }); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), expect.anything(), ); } finally { // Restore for other tests Policy.setAutoAcceptWorkspacePolicies(originalValue); } }); }); ================================================ FILE: packages/cli/src/core/auth.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { performInitialAuth } from './auth.js'; import { type Config, ValidationRequiredError, ProjectIdRequiredError, AuthType, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, }; }); describe('auth', () => { let mockConfig: Config; beforeEach(() => { mockConfig = { refreshAuth: vi.fn(), } as unknown as Config; }); it('should return null if authType is undefined', async () => { const result = await performInitialAuth(mockConfig, undefined); expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); it('should return null on successful auth', async () => { const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); it('should return error message on failed auth', async () => { const error = new Error('Authentication failed'); vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error); const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toEqual({ authError: 'Failed to sign in. Message: Authentication failed', accountSuspensionInfo: null, }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); it('should return null if refreshAuth throws ValidationRequiredError', async () => { vi.mocked(mockConfig.refreshAuth).mockRejectedValue( new ValidationRequiredError('Validation required'), ); const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ response: { data: { error: { code: 403, message: 'This service has been disabled for violation of Terms of Service.', details: [ { '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'TOS_VIOLATION', domain: 'example.googleapis.com', metadata: { appeal_url: 'https://example.com/appeal', appeal_url_link_text: 'Appeal Here', }, }, ], }, }, }, }); const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toEqual({ authError: null, accountSuspensionInfo: { message: 'This service has been disabled for violation of Terms of Service.', appealUrl: 'https://example.com/appeal', appealLinkText: 'Appeal Here', }, }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); it('should return ProjectIdRequiredError message without "Failed to login" prefix', async () => { const projectIdError = new ProjectIdRequiredError(); vi.mocked(mockConfig.refreshAuth).mockRejectedValue(projectIdError); const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toEqual({ authError: 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', accountSuspensionInfo: null, }); expect(result.authError).not.toContain('Failed to login'); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); }); ================================================ FILE: packages/cli/src/core/auth.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type AuthType, type Config, getErrorMessage, ValidationRequiredError, isAccountSuspendedError, ProjectIdRequiredError, } from '@google/gemini-cli-core'; import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitialAuthResult { authError: string | null; accountSuspensionInfo: AccountSuspensionInfo | null; } /** * Handles the initial authentication flow. * @param config The application config. * @param authType The selected auth type. * @returns The auth result with error message and account suspension status. */ export async function performInitialAuth( config: Config, authType: AuthType | undefined, ): Promise { if (!authType) { return { authError: null, accountSuspensionInfo: null }; } try { await config.refreshAuth(authType); // The console.log is intentionally left out here. // We can add a dedicated startup message later if needed. } catch (e) { if (e instanceof ValidationRequiredError) { // Don't treat validation required as a fatal auth error during startup. // This allows the React UI to load and show the ValidationDialog. return { authError: null, accountSuspensionInfo: null }; } const suspendedError = isAccountSuspendedError(e); if (suspendedError) { return { authError: null, accountSuspensionInfo: { message: suspendedError.message, appealUrl: suspendedError.appealUrl, appealLinkText: suspendedError.appealLinkText, }, }; } if (e instanceof ProjectIdRequiredError) { // OAuth succeeded but account setup requires project ID // Show the error message directly without "Failed to login" prefix return { authError: getErrorMessage(e), accountSuspensionInfo: null, }; } return { authError: `Failed to sign in. Message: ${getErrorMessage(e)}`, accountSuspensionInfo: null, }; } return { authError: null, accountSuspensionInfo: null }; } ================================================ FILE: packages/cli/src/core/initializer.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { initializeApp } from './initializer.js'; import { IdeClient, logIdeConnection, logCliConfiguration, type Config, } from '@google/gemini-cli-core'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; import { type LoadedSettings } from '../config/settings.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, IdeClient: { getInstance: vi.fn(), }, logIdeConnection: vi.fn(), logCliConfiguration: vi.fn(), StartSessionEvent: vi.fn(), IdeConnectionEvent: vi.fn(), }; }); vi.mock('./auth.js', () => ({ performInitialAuth: vi.fn(), })); vi.mock('./theme.js', () => ({ validateTheme: vi.fn(), })); describe('initializer', () => { let mockConfig: { getToolRegistry: ReturnType; getIdeMode: ReturnType; getGeminiMdFileCount: ReturnType; }; let mockSettings: LoadedSettings; let mockIdeClient: { connect: ReturnType; }; beforeEach(() => { vi.clearAllMocks(); mockConfig = { getToolRegistry: vi.fn(), getIdeMode: vi.fn().mockReturnValue(false), getGeminiMdFileCount: vi.fn().mockReturnValue(5), }; mockSettings = { merged: { security: { auth: { selectedType: 'oauth', }, }, }, } as unknown as LoadedSettings; mockIdeClient = { connect: vi.fn(), }; vi.mocked(IdeClient.getInstance).mockResolvedValue( mockIdeClient as unknown as IdeClient, ); vi.mocked(performInitialAuth).mockResolvedValue({ authError: null, accountSuspensionInfo: null, }); vi.mocked(validateTheme).mockReturnValue(null); }); it('should initialize correctly in non-IDE mode', async () => { const result = await initializeApp( mockConfig as unknown as Config, mockSettings, ); expect(result).toEqual({ authError: null, accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, }); expect(performInitialAuth).toHaveBeenCalledWith(mockConfig, 'oauth'); expect(validateTheme).toHaveBeenCalledWith(mockSettings); expect(logCliConfiguration).toHaveBeenCalled(); expect(IdeClient.getInstance).not.toHaveBeenCalled(); }); it('should initialize correctly in IDE mode', async () => { mockConfig.getIdeMode.mockReturnValue(true); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, ); expect(result).toEqual({ authError: null, accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, }); expect(IdeClient.getInstance).toHaveBeenCalled(); expect(mockIdeClient.connect).toHaveBeenCalled(); expect(logIdeConnection).toHaveBeenCalledWith( mockConfig as unknown as Config, expect.any(Object), ); }); it('should handle auth error', async () => { vi.mocked(performInitialAuth).mockResolvedValue({ authError: 'Auth failed', accountSuspensionInfo: null, }); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, ); expect(result.authError).toBe('Auth failed'); expect(result.shouldOpenAuthDialog).toBe(true); }); it('should handle undefined auth type', async () => { mockSettings.merged.security.auth.selectedType = undefined; const result = await initializeApp( mockConfig as unknown as Config, mockSettings, ); expect(result.shouldOpenAuthDialog).toBe(true); }); it('should handle theme error', async () => { vi.mocked(validateTheme).mockReturnValue('Theme not found'); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, ); expect(result.themeError).toBe('Theme not found'); }); }); ================================================ FILE: packages/cli/src/core/initializer.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { IdeClient, IdeConnectionEvent, IdeConnectionType, logIdeConnection, type Config, StartSessionEvent, logCliConfiguration, startupProfiler, } from '@google/gemini-cli-core'; import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitializationResult { authError: string | null; accountSuspensionInfo: AccountSuspensionInfo | null; themeError: string | null; shouldOpenAuthDialog: boolean; geminiMdFileCount: number; } /** * Orchestrates the application's startup initialization. * This runs BEFORE the React UI is rendered. * @param config The application config. * @param settings The loaded application settings. * @returns The results of the initialization. */ export async function initializeApp( config: Config, settings: LoadedSettings, ): Promise { const authHandle = startupProfiler.start('authenticate'); const { authError, accountSuspensionInfo } = await performInitialAuth( config, settings.merged.security.auth.selectedType, ); authHandle?.end(); const themeError = validateTheme(settings); const shouldOpenAuthDialog = settings.merged.security.auth.selectedType === undefined || !!authError; logCliConfiguration( config, new StartSessionEvent(config, config.getToolRegistry()), ); if (config.getIdeMode()) { const ideClient = await IdeClient.getInstance(); await ideClient.connect(); logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); } return { authError, accountSuspensionInfo, themeError, shouldOpenAuthDialog, geminiMdFileCount: config.getGeminiMdFileCount(), }; } ================================================ FILE: packages/cli/src/core/theme.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { validateTheme } from './theme.js'; import { themeManager } from '../ui/themes/theme-manager.js'; import { type LoadedSettings } from '../config/settings.js'; vi.mock('../ui/themes/theme-manager.js', () => ({ themeManager: { findThemeByName: vi.fn(), }, })); describe('theme', () => { let mockSettings: LoadedSettings; beforeEach(() => { vi.clearAllMocks(); mockSettings = { merged: { ui: { theme: 'test-theme', }, }, } as unknown as LoadedSettings; }); it('should return null if theme is found', () => { vi.mocked(themeManager.findThemeByName).mockReturnValue( {} as unknown as ReturnType, ); const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); }); it('should return error message if theme is not found', () => { vi.mocked(themeManager.findThemeByName).mockReturnValue(undefined); const result = validateTheme(mockSettings); expect(result).toBe('Theme "test-theme" not found.'); expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme'); }); it('should return null if theme is undefined', () => { mockSettings.merged.ui.theme = undefined; const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/core/theme.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { themeManager } from '../ui/themes/theme-manager.js'; import { type LoadedSettings } from '../config/settings.js'; /** * Validates the configured theme. * @param settings The loaded application settings. * @returns An error message if the theme is not found, otherwise null. */ export function validateTheme(settings: LoadedSettings): string | null { const effectiveTheme = settings.merged.ui.theme; if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { return `Theme "${effectiveTheme}" not found.`; } return null; } ================================================ FILE: packages/cli/src/deferred.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type MockInstance, } from 'vitest'; import { runDeferredCommand, defer, setDeferredCommand, type DeferredCommand, } from './deferred.js'; import { ExitCodes } from '@google/gemini-cli-core'; import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { createMockSettings } from './test-utils/settings.js'; const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ mockRunExitCleanup: vi.fn(), mockCoreEvents: { emitFeedback: vi.fn(), }, })); vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, coreEvents: mockCoreEvents, }; }); vi.mock('./utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, })); let mockExit: MockInstance; describe('deferred', () => { beforeEach(() => { vi.clearAllMocks(); mockExit = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command }); describe('runDeferredCommand', () => { it('should do nothing if no deferred command is set', async () => { await runDeferredCommand(createMockSettings().merged); expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled(); expect(mockExit).not.toHaveBeenCalled(); }); it('should execute the deferred command if enabled', async () => { const mockHandler = vi.fn(); setDeferredCommand({ handler: mockHandler, argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase, commandName: 'mcp', }); const settings = createMockSettings({ merged: { admin: { mcp: { enabled: true } } }, }).merged; await runDeferredCommand(settings); expect(mockHandler).toHaveBeenCalled(); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => { setDeferredCommand({ handler: vi.fn(), argv: {} as ArgumentsCamelCase, commandName: 'mcp', }); const settings = createMockSettings({ merged: { admin: { mcp: { enabled: false } } }, }).merged; await runDeferredCommand(settings); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => { setDeferredCommand({ handler: vi.fn(), argv: {} as ArgumentsCamelCase, commandName: 'extensions', }); const settings = createMockSettings({ merged: { admin: { extensions: { enabled: false } } }, }).merged; await runDeferredCommand(settings); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => { setDeferredCommand({ handler: vi.fn(), argv: {} as ArgumentsCamelCase, commandName: 'skills', }); const settings = createMockSettings({ merged: { admin: { skills: { enabled: false } } }, }).merged; await runDeferredCommand(settings); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); it('should execute if admin settings are undefined (default implicit enable)', async () => { const mockHandler = vi.fn(); setDeferredCommand({ handler: mockHandler, argv: {} as ArgumentsCamelCase, commandName: 'mcp', }); const settings = createMockSettings({}).merged; // No admin settings await runDeferredCommand(settings); expect(mockHandler).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); }); describe('defer', () => { it('should wrap a command module and defer execution', async () => { const originalHandler = vi.fn(); const commandModule: CommandModule = { command: 'test', describe: 'test command', handler: originalHandler, }; const deferredModule = defer(commandModule); expect(deferredModule.command).toBe(commandModule.command); // Execute the wrapper handler const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase; await deferredModule.handler(argv); // Should check that it set the deferred command, but didn't run original handler yet expect(originalHandler).not.toHaveBeenCalled(); // Now manually run it to verify it captured correctly await runDeferredCommand(createMockSettings().merged); expect(originalHandler).toHaveBeenCalledWith( expect.objectContaining({ settings: expect.objectContaining({ admin: expect.objectContaining({ extensions: expect.objectContaining({ enabled: true }), }), }), }), ); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); it('should use parentCommandName if provided', async () => { const commandModule: CommandModule = { command: 'subcommand', describe: 'sub command', handler: vi.fn(), }; const deferredModule = defer(commandModule, 'parent'); await deferredModule.handler({} as ArgumentsCamelCase); const deferredMcp = defer(commandModule, 'mcp'); await deferredMcp.handler({} as ArgumentsCamelCase); const mcpSettings = createMockSettings({ merged: { admin: { mcp: { enabled: false } } }, }).merged; await runDeferredCommand(mcpSettings); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); it('should fallback to unknown if no parentCommandName is provided', async () => { const mockHandler = vi.fn(); const commandModule: CommandModule = { command: ['foo', 'infoo'], describe: 'foo command', handler: mockHandler, }; const deferredModule = defer(commandModule); await deferredModule.handler({} as ArgumentsCamelCase); // Verify it runs even if all known commands are disabled, // confirming it didn't capture 'mcp', 'extensions', or 'skills' // and defaulted to 'unknown' (or something else safe). const settings = createMockSettings({ merged: { admin: { mcp: { enabled: false }, extensions: { enabled: false }, skills: { enabled: false }, }, }, }).merged; await runDeferredCommand(settings); expect(mockHandler).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); }); }); }); ================================================ FILE: packages/cli/src/deferred.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { coreEvents, ExitCodes, getAdminErrorMessage, } from '@google/gemini-cli-core'; import { runExitCleanup } from './utils/cleanup.js'; import type { MergedSettings } from './config/settings.js'; import process from 'node:process'; export interface DeferredCommand { handler: (argv: ArgumentsCamelCase) => void | Promise; argv: ArgumentsCamelCase; commandName: string; } let deferredCommand: DeferredCommand | undefined; export function setDeferredCommand(command: DeferredCommand) { deferredCommand = command; } export async function runDeferredCommand(settings: MergedSettings) { if (!deferredCommand) { return; } const adminSettings = settings.admin; const commandName = deferredCommand.commandName; if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) { coreEvents.emitFeedback( 'error', getAdminErrorMessage('MCP', undefined /* config */), ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } if ( commandName === 'extensions' && adminSettings?.extensions?.enabled === false ) { coreEvents.emitFeedback( 'error', getAdminErrorMessage('Extensions', undefined /* config */), ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } if (commandName === 'skills' && adminSettings?.skills?.enabled === false) { coreEvents.emitFeedback( 'error', getAdminErrorMessage('Agent skills', undefined /* config */), ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } // Inject settings into argv const argvWithSettings = { ...deferredCommand.argv, settings, }; await deferredCommand.handler(argvWithSettings); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } /** * Wraps a command's handler to defer its execution. * It stores the handler and arguments in a singleton `deferredCommand` variable. */ export function defer( commandModule: CommandModule, parentCommandName?: string, ): CommandModule { return { ...commandModule, handler: (argv: ArgumentsCamelCase) => { setDeferredCommand({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion handler: commandModule.handler as ( argv: ArgumentsCamelCase, ) => void | Promise, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion argv: argv as unknown as ArgumentsCamelCase, commandName: parentCommandName || 'unknown', }); }, }; } ================================================ FILE: packages/cli/src/gemini.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance, type Mock, } from 'vitest'; import { main, setupUnhandledRejectionHandler, validateDnsResolutionOrder, startInteractiveUI, getNodeMemoryArgs, } from './gemini.js'; import { loadCliConfig, parseArguments, type CliArgs, } from './config/config.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js'; import { start_sandbox } from './utils/sandbox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import os from 'node:os'; import v8 from 'node:v8'; import { loadSettings, type LoadedSettings } from './config/settings.js'; import { createMockConfig, createMockSettings, } from './test-utils/mockConfig.js'; import { appEvents, AppEvent } from './utils/events.js'; import { type Config, type ResumedSessionData, type StartupWarning, WarningPriority, debugLogger, coreEvents, AuthType, } from '@google/gemini-cli-core'; import { act } from 'react'; import { type InitializationResult } from './core/initializer.js'; import { runNonInteractive } from './nonInteractiveCli.js'; // Hoisted constants and mocks const performance = vi.hoisted(() => ({ now: vi.fn(), })); vi.stubGlobal('performance', performance); const runNonInteractiveSpy = vi.hoisted(() => vi.fn()); vi.mock('./nonInteractiveCli.js', () => ({ runNonInteractive: runNonInteractiveSpy, })); const terminalNotificationMocks = vi.hoisted(() => ({ notifyViaTerminal: vi.fn().mockResolvedValue(true), buildRunEventNotificationContent: vi.fn(() => ({ title: 'Session complete', body: 'done', subtitle: 'Run finished', })), })); vi.mock('./utils/terminalNotifications.js', () => ({ notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal, buildRunEventNotificationContent: terminalNotificationMocks.buildRunEventNotificationContent, })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, recordSlowRender: vi.fn(), logUserPrompt: vi.fn(), writeToStdout: vi.fn((...args) => process.stdout.write( ...(args as Parameters), ), ), patchStdio: vi.fn(() => () => {}), createWorkingStdio: vi.fn(() => ({ stdout: { write: vi.fn((...args) => process.stdout.write( ...(args as Parameters), ), ), columns: 80, rows: 24, on: vi.fn(), removeListener: vi.fn(), }, stderr: { write: vi.fn(), }, })), enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), enterAlternateScreen: vi.fn(), disableLineWrapping: vi.fn(), getVersion: vi.fn(() => Promise.resolve('1.0.0')), startupProfiler: { start: vi.fn(() => ({ end: vi.fn(), })), flush: vi.fn(), }, ClearcutLogger: { getInstance: vi.fn(() => ({ logStartSessionEvent: vi.fn().mockResolvedValue(undefined), logEndSessionEvent: vi.fn().mockResolvedValue(undefined), logUserPrompt: vi.fn(), addDefaultFields: vi.fn((data) => data), })), clearInstance: vi.fn(), }, coreEvents: { ...actual.coreEvents, emitFeedback: vi.fn(), emitConsoleLog: vi.fn(), listenerCount: vi.fn().mockReturnValue(0), on: vi.fn(), off: vi.fn(), drainBacklogs: vi.fn(), }, }; }); vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, render: vi.fn((_node, options) => { if (options.alternateBuffer) { options.stdout.write('\x1b[?7l'); } // Simulate rendering time for recordSlowRender test const start = performance.now(); const end = performance.now(); if (options.onRender) { options.onRender({ renderTime: end - start }); } return { unmount: vi.fn(), rerender: vi.fn(), cleanup: vi.fn(), waitUntilExit: vi.fn(), }; }), }; }); // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { constructor(readonly code?: string | number | null | undefined) { super('PROCESS_EXIT_MOCKED'); this.name = 'MockProcessExitError'; } } // Mock dependencies vi.mock('./config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn().mockImplementation(() => ({ merged: actual.getDefaultsFromSchema(), workspace: { settings: {} }, errors: [], })), saveModelChange: vi.fn(), getDefaultsFromSchema: actual.getDefaultsFromSchema, }; }); vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { detectCapabilities: vi.fn(), getTerminalBackgroundColor: vi.fn(), }, })); vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()), parseArguments: vi.fn().mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, }), isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ readPackageUp: vi.fn().mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, packageJson: { name: 'test-pkg', version: 'test-version' }, path: '/fake/path/package.json', }), })); vi.mock('update-notifier', () => ({ default: vi.fn(() => ({ notify: vi.fn(), })), })); vi.mock('./utils/events.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, appEvents: { emit: vi.fn(), }, }; }); import * as readStdinModule from './utils/readStdin.js'; vi.mock('./utils/sandbox.js', () => ({ sandbox_command: vi.fn(() => ''), // Default to no sandbox command start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves })); vi.mock('./utils/relaunch.js', () => ({ relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined), relaunchOnExitCode: vi.fn(async (fn) => { await fn(); }), })); vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn().mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, command: 'docker', image: 'test-image', }), })); vi.mock('./deferred.js', () => ({ runDeferredCommand: vi.fn().mockResolvedValue(undefined), setDeferredCommand: vi.fn(), defer: vi.fn((m) => m), })); vi.mock('./ui/utils/mouse.js', () => ({ enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), isIncompleteMouseSequence: vi.fn(), })); vi.mock('./validateNonInterActiveAuth.js', () => ({ validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'), })); describe('gemini.tsx main function', () => { let originalIsTTY: boolean | undefined; let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = []; beforeEach(() => { // Store and clear sandbox-related env variables to ensure a consistent test environment vi.stubEnv('GEMINI_SANDBOX', ''); vi.stubEnv('SANDBOX', ''); vi.stubEnv('SHPOOL_SESSION_NAME', ''); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); originalIsTTY = process.stdin.isTTY; // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = true; }); afterEach(() => { const currentListeners = process.listeners('unhandledRejection'); currentListeners.forEach((listener) => { if (!initialUnhandledRejectionListeners.includes(listener)) { process.removeListener('unhandledRejection', listener); } }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should log unhandled promise rejections and open debug console on first error', async () => { const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); const appEventsMock = vi.mocked(appEvents); const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); const rejectionError = new Error('Test unhandled rejection'); setupUnhandledRejectionHandler(); // Simulate an unhandled rejection. // We are not using Promise.reject here as vitest will catch it. // Instead we will dispatch the event manually. process.emit('unhandledRejection', rejectionError, Promise.resolve()); // We need to wait for the rejection handler to be called. await new Promise(process.nextTick); expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Unhandled Promise Rejection'), ); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Please file a bug report using the /bug tool.'), ); // Simulate a second rejection const secondRejectionError = new Error('Second test unhandled rejection'); process.emit('unhandledRejection', secondRejectionError, Promise.resolve()); await new Promise(process.nextTick); // Ensure emit was only called once for OpenDebugConsole const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter( (call) => call[0] === AppEvent.OpenDebugConsole, ); expect(openDebugConsoleCalls.length).toBe(1); // Avoid the process.exit error from being thrown. processExitSpy.mockRestore(); }); }); describe('setWindowTitle', () => { it('should set window title when hideWindowTitle is false', async () => { // setWindowTitle is not exported, but we can test its effect if we had a way to call it. // Since we can't easily call it directly without exporting it, we skip direct testing // and rely on startInteractiveUI tests which call it. }); }); describe('initializeOutputListenersAndFlush', () => { afterEach(() => { vi.restoreAllMocks(); }); it('should flush backlogs and setup listeners if no listeners exist', async () => { const { coreEvents } = await import('@google/gemini-cli-core'); const { initializeOutputListenersAndFlush } = await import('./gemini.js'); // Mock listenerCount to return 0 vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); const drainSpy = vi.spyOn(coreEvents, 'drainBacklogs'); initializeOutputListenersAndFlush(); expect(drainSpy).toHaveBeenCalled(); // We can't easily check if listeners were added without access to the internal state of coreEvents, // but we can verify that drainBacklogs was called. }); }); describe('getNodeMemoryArgs', () => { let osTotalMemSpy: MockInstance; let v8GetHeapStatisticsSpy: MockInstance; beforeEach(() => { osTotalMemSpy = vi.spyOn(os, 'totalmem'); v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics'); delete process.env['GEMINI_CLI_NO_RELAUNCH']; }); afterEach(() => { vi.restoreAllMocks(); }); it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => { process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; expect(getNodeMemoryArgs(false)).toEqual([]); }); it('should return empty array if current heap limit is sufficient', () => { osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB v8GetHeapStatisticsSpy.mockReturnValue({ heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB }); // Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed. expect(getNodeMemoryArgs(false)).toEqual([]); }); it('should return memory args if current heap limit is insufficient', () => { osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB v8GetHeapStatisticsSpy.mockReturnValue({ heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB }); // Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed. expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']); }); it('should log debug info when isDebugMode is true', () => { const debugSpy = vi.spyOn(debugLogger, 'debug'); osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); v8GetHeapStatisticsSpy.mockReturnValue({ heap_size_limit: 4 * 1024 * 1024 * 1024, }); getNodeMemoryArgs(true); expect(debugSpy).toHaveBeenCalledWith( expect.stringContaining('Current heap size'), ); expect(debugSpy).toHaveBeenCalledWith( expect.stringContaining('Need to relaunch with more memory'), ); }); }); describe('gemini.tsx main function kitty protocol', () => { let originalEnvNoRelaunch: string | undefined; let originalIsTTY: boolean | undefined; let originalIsRaw: boolean | undefined; let setRawModeSpy: MockInstance< (mode: boolean) => NodeJS.ReadStream & { fd: 0 } >; beforeEach(() => { // Set no relaunch in tests since process spawning causing issues in tests originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(process.stdin as any).setRawMode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).setRawMode = vi.fn(); } setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode'); originalIsTTY = process.stdin.isTTY; originalIsRaw = process.stdin.isRaw; // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isRaw = false; }); afterEach(() => { // Restore original env variables if (originalEnvNoRelaunch !== undefined) { process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; } else { delete process.env['GEMINI_CLI_NO_RELAUNCH']; } // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = originalIsTTY; // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isRaw = originalIsRaw; vi.restoreAllMocks(); }); it('should call setRawMode and detectCapabilities when isInteractive is true', async () => { vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => true, getQuestion: () => '', getSandbox: () => undefined, }), ); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: {}, }, }), ); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, sandbox: undefined, debug: undefined, prompt: undefined, promptInteractive: undefined, query: undefined, yolo: undefined, approvalMode: undefined, policy: undefined, adminPolicy: undefined, allowedMcpServerNames: undefined, allowedTools: undefined, experimentalAcp: undefined, extensions: undefined, listExtensions: undefined, includeDirectories: undefined, screenReader: undefined, useWriteTodos: undefined, resume: undefined, listSessions: undefined, deleteSession: undefined, outputFormat: undefined, fakeResponses: undefined, recordResponses: undefined, rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, }); await act(async () => { await main(); }); expect(setRawModeSpy).toHaveBeenCalledWith(true); expect(terminalCapabilityManager.detectCapabilities).toHaveBeenCalledTimes( 1, ); }); it.each([ { flag: 'listExtensions' }, { flag: 'listSessions' }, { flag: 'deleteSession', value: 'session-id' }, ])('should handle --$flag flag', async ({ flag, value }) => { const { listSessions, deleteSession } = await import('./utils/sessions.js'); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: {}, }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any const mockConfig = createMockConfig({ isInteractive: () => false, getQuestion: () => '', getSandbox: () => undefined, getListExtensions: () => flag === 'listExtensions', getListSessions: () => flag === 'listSessions', getDeleteSession: () => (flag === 'deleteSession' ? value : undefined), getExtensions: () => [ { name: 'ext1', id: 'ext1', version: '1.0.0', isActive: true, path: '/path/to/ext1', contextFiles: [], }, ], }); vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); vi.mock('./utils/sessions.js', () => ({ listSessions: vi.fn(), deleteSession: vi.fn(), })); const debugLoggerLogSpy = vi .spyOn(debugLogger, 'log') .mockImplementation(() => {}); process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } finally { delete process.env['GEMINI_API_KEY']; } if (flag === 'listExtensions') { expect(debugLoggerLogSpy).toHaveBeenCalledWith( expect.stringContaining('ext1'), ); } else if (flag === 'listSessions') { expect(listSessions).toHaveBeenCalledWith(mockConfig); } else if (flag === 'deleteSession') { expect(deleteSession).toHaveBeenCalledWith(mockConfig, value); } expect(processExitSpy).toHaveBeenCalledWith(0); processExitSpy.mockRestore(); }); it('should handle sandbox activation', async () => { vi.stubEnv('SANDBOX', ''); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: { selectedType: 'google' } }, ui: {}, }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); const mockConfig = createMockConfig({ isInteractive: () => false, getQuestion: () => '', getSandbox: () => createMockSandboxConfig({ command: 'docker', image: 'test-image' }), }); vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); vi.mocked(loadSandboxConfig).mockResolvedValue( createMockSandboxConfig({ command: 'docker', image: 'test-image', }), ); process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } finally { delete process.env['GEMINI_API_KEY']; } expect(start_sandbox).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); processExitSpy.mockRestore(); }); it('should log warning when theme is not found', async () => { const { themeManager } = await import('./ui/themes/theme-manager.js'); const debugLoggerWarnSpy = vi .spyOn(debugLogger, 'warn') .mockImplementation(() => {}); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'non-existent-theme' }, }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => 'test', getSandbox: () => undefined, }), ); vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } finally { delete process.env['GEMINI_API_KEY']; } expect(debugLoggerWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Warning: Theme "non-existent-theme" not found.'), ); processExitSpy.mockRestore(); }); it('should handle session selector error', async () => { const { SessionSelector } = await import('./utils/sessionUtils.js'); vi.mocked(SessionSelector).mockImplementation( () => ({ resolveSession: vi .fn() .mockRejectedValue(new Error('Session not found')), }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any ); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, resume: 'session-id', } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => true, getQuestion: () => '', getSandbox: () => undefined, }), ); try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } expect(emitFeedbackSpy).toHaveBeenCalledWith( 'error', expect.stringContaining('Error resuming session: Session not found'), ); expect(processExitSpy).toHaveBeenCalledWith(42); processExitSpy.mockRestore(); emitFeedbackSpy.mockRestore(); }); it('should start normally with a warning when no sessions found for resume', async () => { const { SessionSelector, SessionError } = await import( './utils/sessionUtils.js' ); vi.mocked(SessionSelector).mockImplementation( () => ({ resolveSession: vi .fn() .mockRejectedValue(SessionError.noSessionsFound()), }) as unknown as InstanceType, ); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, resume: 'latest', } as unknown as CliArgs); vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => true, getQuestion: () => '', getSandbox: () => undefined, }), ); await main(); // Should NOT have crashed expect(processExitSpy).not.toHaveBeenCalled(); // Should NOT have emitted a feedback error expect(emitFeedbackSpy).not.toHaveBeenCalledWith( 'error', expect.stringContaining('Error resuming session'), ); processExitSpy.mockRestore(); emitFeedbackSpy.mockRestore(); }); it.skip('should log error when cleanupExpiredSessions fails', async () => { const { cleanupExpiredSessions } = await import( './utils/sessionCleanup.js' ); vi.mocked(cleanupExpiredSessions).mockRejectedValue( new Error('Cleanup failed'), ); const debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: {} }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => 'test', getSandbox: () => undefined, }), ); // The mock is already set up at the top of the test try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining( 'Failed to cleanup expired sessions: Cleanup failed', ), ); expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure processExitSpy.mockRestore(); }); it('should read from stdin in non-interactive mode', async () => { vi.stubEnv('SANDBOX', 'true'); vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); const readStdinSpy = vi .spyOn(readStdinModule, 'readStdin') .mockResolvedValue('stdin-data'); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { advanced: {}, security: { auth: {} }, ui: {} }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => 'test-question', getSandbox: () => undefined, }), ); // Mock stdin to be non-TTY // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = false; process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } finally { delete process.env['GEMINI_API_KEY']; } expect(readStdinSpy).toHaveBeenCalled(); // In this test setup, runNonInteractive might be called on the mocked module, // but we need to ensure we are checking the correct spy instance. // Since vi.mock is hoisted, runNonInteractiveSpy is defined early. expect(runNonInteractive).toHaveBeenCalled(); const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0]; expect(callArgs.input).toBe('stdin-data\n\ntest-question'); expect( terminalNotificationMocks.buildRunEventNotificationContent, ).not.toHaveBeenCalled(); expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); processExitSpy.mockRestore(); }); }); describe('gemini.tsx main function exit codes', () => { let originalEnvNoRelaunch: string | undefined; let originalIsTTY: boolean | undefined; beforeEach(() => { originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; vi.spyOn(process, 'exit').mockImplementation((code) => { throw new MockProcessExitError(code); }); // Mock stderr to avoid cluttering output vi.spyOn(process.stderr, 'write').mockImplementation(() => true); originalIsTTY = process.stdin.isTTY; }); afterEach(() => { if (originalEnvNoRelaunch !== undefined) { process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; } else { delete process.env['GEMINI_CLI_NO_RELAUNCH']; } // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = originalIsTTY; vi.restoreAllMocks(); }); it('should exit with 42 for invalid input combination (prompt-interactive with non-TTY)', async () => { vi.mocked(loadCliConfig).mockResolvedValue(createMockConfig()); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { security: { auth: {} }, ui: {} }, }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, promptInteractive: true, } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = false; try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(42); } }); it('should exit with 41 for auth failure during sandbox setup', async () => { vi.stubEnv('SANDBOX', ''); vi.mocked(loadSandboxConfig).mockResolvedValue( createMockSandboxConfig({ command: 'docker', image: 'test-image', }), ); vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), getRemoteAdminSettings: vi.fn().mockReturnValue(undefined), isInteractive: vi.fn().mockReturnValue(true), }), ); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { security: { auth: { selectedType: 'google', useExternal: false } }, }, }), ); vi.mocked(parseArguments).mockResolvedValue({} as CliArgs); try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(41); } }); it('should exit with 42 for session resume failure', async () => { vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => 'test', getSandbox: () => undefined, }), ); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { security: { auth: {} }, ui: {} }, }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, resume: 'invalid-session', } as unknown as CliArgs); vi.mock('./utils/sessionUtils.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, SessionSelector: vi.fn().mockImplementation(() => ({ resolveSession: vi .fn() .mockRejectedValue(new Error('Session not found')), })), }; }); process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(42); } finally { delete process.env['GEMINI_API_KEY']; } }); it('should exit with 42 for no input provided', async () => { vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => '', getSandbox: () => undefined, }), ); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { security: { auth: {} }, ui: {} }, }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = true; process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(42); } finally { delete process.env['GEMINI_API_KEY']; } }); it('should validate and refresh auth in non-interactive mode when no auth type is selected but env var is present', async () => { const refreshAuthSpy = vi.fn(); vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ isInteractive: () => false, getQuestion: () => 'test prompt', getSandbox: () => undefined, refreshAuth: refreshAuthSpy, }), ); vi.mocked(validateNonInteractiveAuth).mockResolvedValue( AuthType.USE_GEMINI, ); vi.mocked(loadSettings).mockReturnValue( createMockSettings({ merged: { security: { auth: { selectedType: undefined } }, ui: {} }, }), ); vi.mocked(parseArguments).mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, } as unknown as CliArgs); runNonInteractiveSpy.mockImplementation(() => Promise.resolve()); const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { throw new MockProcessExitError(code); }); process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; } finally { delete process.env['GEMINI_API_KEY']; processExitSpy.mockRestore(); } expect(refreshAuthSpy).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); }); describe('validateDnsResolutionOrder', () => { let debugLoggerWarnSpy: ReturnType; beforeEach(() => { debugLoggerWarnSpy = vi .spyOn(debugLogger, 'warn') .mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); it('should return "ipv4first" when the input is "ipv4first"', () => { expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first'); expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); }); it('should return "verbatim" when the input is "verbatim"', () => { expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim'); expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); }); it('should return the default "ipv4first" when the input is undefined', () => { expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first'); expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); }); it('should return the default "ipv4first" and log a warning for an invalid string', () => { expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); expect(debugLoggerWarnSpy).toHaveBeenCalledExactlyOnceWith( 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', ); }); }); describe('project hooks loading based on trust', () => { let loadCliConfig: Mock; let loadSettings: Mock; let parseArguments: Mock; beforeEach(async () => { // Dynamically import and get the mocked functions const configModule = await import('./config/config.js'); loadCliConfig = vi.mocked(configModule.loadCliConfig); parseArguments = vi.mocked(configModule.parseArguments); parseArguments.mockResolvedValue({ enabled: true, allowedPaths: [], networkAccess: false, startupMessages: [], }); const settingsModule = await import('./config/settings.js'); loadSettings = vi.mocked(settingsModule.loadSettings); vi.clearAllMocks(); // Mock the main function's dependencies to isolate the config loading part vi.mock('./nonInteractiveCli.js', () => ({ runNonInteractive: vi.fn().mockResolvedValue(undefined), })); vi.spyOn(process, 'exit').mockImplementation((() => {}) as unknown as ( code?: string | number | null, ) => never); // Default mock implementation for loadCliConfig loadCliConfig.mockResolvedValue( createMockConfig({ getQuestion: vi.fn().mockReturnValue('test question'), }), ); }); afterEach(() => { vi.restoreAllMocks(); }); it('should load project hooks when workspace is trusted', async () => { const hooks = { 'before-model': 'echo "trusted"' }; loadSettings.mockReturnValue( createMockSettings({ workspace: { isTrusted: true, settings: { hooks }, }, merged: { security: { auth: { selectedType: 'google' } }, }, }), ); await main(); expect(loadCliConfig).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ projectHooks: hooks, }), ); }); it('should NOT load project hooks when workspace is not trusted', async () => { loadSettings.mockReturnValue( createMockSettings({ workspace: { isTrusted: false, settings: {}, }, merged: { security: { auth: { selectedType: 'google' } }, }, }), ); await main(); expect(loadCliConfig).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ projectHooks: undefined, }), ); }); }); describe('startInteractiveUI', () => { // Mock dependencies const mockConfig = createMockConfig({ getProjectRoot: () => '/root', getScreenReader: () => false, getDebugMode: () => false, getUseAlternateBuffer: () => true, }); const mockSettings = { merged: { ui: { hideWindowTitle: false, useAlternateBuffer: true, incrementalRendering: true, }, general: { debugKeystrokeLogging: false, }, }, } as LoadedSettings; const mockStartupWarnings: StartupWarning[] = [ { id: 'w1', message: 'warning1', priority: WarningPriority.High }, ]; const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, }; vi.mock('./ui/utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(() => Promise.resolve(null)), })); vi.mock('./utils/cleanup.js', () => ({ cleanupCheckpoints: vi.fn(() => Promise.resolve()), registerCleanup: vi.fn(), runExitCleanup: vi.fn(), registerSyncCleanup: vi.fn(), registerTelemetryConfig: vi.fn(), setupSignalHandlers: vi.fn(), setupTtyCheck: vi.fn(() => vi.fn()), })); beforeEach(() => { vi.stubEnv('SHPOOL_SESSION_NAME', ''); }); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); async function startTestInteractiveUI( config: Config, settings: LoadedSettings, startupWarnings: StartupWarning[], workspaceRoot: string, resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { await act(async () => { await startInteractiveUI( config, settings, startupWarnings, workspaceRoot, resumedSessionData, initializationResult, ); }); } it('should render the UI with proper React context and exitOnCtrlC disabled', async () => { const { render } = await import('ink'); const renderSpy = vi.mocked(render); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); // Verify render was called with correct options const [reactElement, options] = renderSpy.mock.calls[0]; // Verify render options expect(options).toEqual( expect.objectContaining({ alternateBuffer: true, exitOnCtrlC: false, incrementalRendering: true, isScreenReaderEnabled: false, onRender: expect.any(Function), patchConsole: false, }), ); // Verify React element structure is valid (but don't deep dive into JSX internals) expect(reactElement).toBeDefined(); }); it('should enable mouse events when alternate buffer is enabled', async () => { const { enableMouseEvents } = await import('@google/gemini-cli-core'); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); expect(enableMouseEvents).toHaveBeenCalled(); }); it('should patch console', async () => { const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js'); const patchSpy = vi.spyOn(ConsolePatcher.prototype, 'patch'); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); expect(patchSpy).toHaveBeenCalled(); }); it('should perform all startup tasks in correct order', async () => { const { getVersion } = await import('@google/gemini-cli-core'); const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); const { registerCleanup } = await import('./utils/cleanup.js'); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); // Verify all startup tasks were called expect(getVersion).toHaveBeenCalledTimes(1); // 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check expect(registerCleanup).toHaveBeenCalledTimes(5); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; expect(typeof cleanupFn).toBe('function'); // checkForUpdates should be called asynchronously (not waited for) // We need a small delay to let it execute await new Promise((resolve) => setTimeout(resolve, 0)); expect(checkForUpdates).toHaveBeenCalledTimes(1); }); it('should not recordSlowRender when less than threshold', async () => { const { recordSlowRender } = await import('@google/gemini-cli-core'); performance.now.mockReturnValueOnce(0); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); expect(recordSlowRender).not.toHaveBeenCalled(); }); it('should call recordSlowRender when more than threshold', async () => { const { recordSlowRender } = await import('@google/gemini-cli-core'); performance.now.mockReturnValueOnce(0); performance.now.mockReturnValueOnce(300); await startTestInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); expect(recordSlowRender).toHaveBeenCalledWith(mockConfig, 300); }); it.each([ { screenReader: true, expectedCalls: [], name: 'should not disable line wrapping in screen reader mode', }, { screenReader: false, expectedCalls: [['\x1b[?7l']], name: 'should disable line wrapping when not in screen reader mode', }, ])('$name', async ({ screenReader, expectedCalls }) => { const writeSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); const mockConfigWithScreenReader = { ...mockConfig, getScreenReader: () => screenReader, } as Config; await startTestInteractiveUI( mockConfigWithScreenReader, mockSettings, mockStartupWarnings, mockWorkspaceRoot, undefined, mockInitializationResult, ); if (expectedCalls.length > 0) { expect(writeSpy).toHaveBeenCalledWith(expectedCalls[0][0]); } else { expect(writeSpy).not.toHaveBeenCalledWith('\x1b[?7l'); } writeSpy.mockRestore(); }); }); ================================================ FILE: packages/cli/src/gemini.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type StartupWarning, WarningPriority, type Config, type ResumedSessionData, type OutputPayload, type ConsoleLogPayload, type UserFeedbackPayload, sessionId, logUserPrompt, AuthType, UserPromptEvent, coreEvents, CoreEvent, getOauthClient, patchStdio, writeToStdout, writeToStderr, shouldEnterAlternateScreen, startupProfiler, ExitCodes, SessionStartSource, SessionEndReason, ValidationCancelledError, ValidationRequiredError, type AdminControlsSettings, debugLogger, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; import { start_sandbox } from './utils/sandbox.js'; import { loadSettings, SettingScope, type DnsResolutionOrder, type LoadedSettings, } from './config/settings.js'; import { loadTrustedFolders, type TrustedFoldersError, } from './config/trustedFolders.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { runNonInteractive } from './nonInteractiveCli.js'; import { cleanupCheckpoints, registerCleanup, registerSyncCleanup, runExitCleanup, registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, } from './utils/sessionCleanup.js'; import { initializeApp, type InitializationResult, } from './core/initializer.js'; import { validateAuthMethod } from './config/auth.js'; import { runAcpClient } from './acp/acpClient.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { appEvents, AppEvent } from './utils/events.js'; import { SessionError, SessionSelector } from './utils/sessionUtils.js'; import { relaunchAppInChildProcess, relaunchOnExitCode, } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; import { deleteSession, listSessions } from './utils/sessions.js'; import { createPolicyUpdater } from './config/policy.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { runDeferredCommand } from './deferred.js'; import { cleanupBackgroundLogs } from './utils/logCleanup.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { const defaultValue: DnsResolutionOrder = 'ipv4first'; if (order === undefined) { return defaultValue; } if (order === 'ipv4first' || order === 'verbatim') { return order; } // We don't want to throw here, just warn and use the default. debugLogger.warn( `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, ); return defaultValue; } export function getNodeMemoryArgs(isDebugMode: boolean): string[] { const totalMemoryMB = os.totalmem() / (1024 * 1024); const heapStats = v8.getHeapStatistics(); const currentMaxOldSpaceSizeMb = Math.floor( heapStats.heap_size_limit / 1024 / 1024, ); // Set target to 50% of total memory const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); if (isDebugMode) { debugLogger.debug( `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, ); } if (process.env['GEMINI_CLI_NO_RELAUNCH']) { return []; } if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { if (isDebugMode) { debugLogger.debug( `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, ); } return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`]; } return []; } export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; process.on('unhandledRejection', (reason, _promise) => { const errorMessage = `========================================= This is an unexpected error. Please file a bug report using the /bug tool. CRITICAL: Unhandled Promise Rejection! ========================================= Reason: ${reason}${ reason instanceof Error && reason.stack ? ` Stack trace: ${reason.stack}` : '' }`; debugLogger.error(errorMessage); if (!unhandledRejectionOccurred) { unhandledRejectionOccurred = true; appEvents.emit(AppEvent.OpenDebugConsole); } }); } export async function startInteractiveUI( config: Config, settings: LoadedSettings, startupWarnings: StartupWarning[], workspaceRoot: string = process.cwd(), resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { // Dynamically import the heavy UI module so React/Ink are only parsed when needed const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js'); await doStartUI( config, settings, startupWarnings, workspaceRoot, resumedSessionData, initializationResult, ); } export async function main() { const cliStartupHandle = startupProfiler.start('cli_startup'); // Listen for admin controls from parent process (IPC) in non-sandbox mode. In // sandbox mode, we re-fetch the admin controls from the server once we enter // the sandbox. // TODO: Cache settings in sandbox mode as well. const adminControlsListner = setupAdminControlsListener(); registerCleanup(adminControlsListner.cleanup); const cleanupStdio = patchStdio(); registerSyncCleanup(() => { // This is needed to ensure we don't lose any buffered output. initializeOutputListenersAndFlush(); cleanupStdio(); }); setupUnhandledRejectionHandler(); setupSignalHandlers(); const slashCommandConflictHandler = new SlashCommandConflictHandler(); slashCommandConflictHandler.start(); registerCleanup(() => slashCommandConflictHandler.stop()); const loadSettingsHandle = startupProfiler.start('load_settings'); const settings = loadSettings(); loadSettingsHandle?.end(); // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); }); const trustedFolders = loadTrustedFolders(); trustedFolders.errors.forEach((error: TrustedFoldersError) => { coreEvents.emitFeedback( 'warning', `Error in ${error.path}: ${error.message}`, ); }); await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); const argv = await parseArguments(settings.merged); parseArgsHandle?.end(); if ( (argv.allowedTools && argv.allowedTools.length > 0) || (settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0) ) { coreEvents.emitFeedback( 'warning', 'Warning: --allowed-tools cli argument and tools.allowed in settings.json are deprecated and will be removed in 1.0: Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', ); } if ( settings.merged.tools?.exclude && settings.merged.tools.exclude.length > 0 ) { coreEvents.emitFeedback( 'warning', 'Warning: tools.exclude in settings.json is deprecated and will be removed in 1.0. Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', ); } if (argv.startupMessages) { argv.startupMessages.forEach((msg) => { coreEvents.emitFeedback('info', msg); }); } // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { writeToStderr( 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.\n', ); await runExitCleanup(); process.exit(ExitCodes.FATAL_INPUT_ERROR); } const isDebugMode = cliConfig.isDebugMode(argv); const consolePatcher = new ConsolePatcher({ stderr: true, debugMode: isDebugMode, onNewMessage: (msg) => { coreEvents.emitConsoleLog(msg.type, msg.content); }, }); consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder), ); // Set a default auth type if one isn't set or is set to a legacy type if ( !settings.merged.security.auth.selectedType || settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL ) { if ( process.env['CLOUD_SHELL'] === 'true' || process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true' ) { settings.setValue( SettingScope.User, 'security.auth.selectedType', AuthType.COMPUTE_ADC, ); } } const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, }); adminControlsListner.setConfig(partialConfig); // Refresh auth to fetch remote admin settings from CCPA and before entering // the sandbox because the sandbox will interfere with the Oauth2 web // redirect. let initialAuthFailed = false; if (!settings.merged.security.auth.useExternal) { try { if ( partialConfig.isInteractive() && settings.merged.security.auth.selectedType ) { const err = validateAuthMethod( settings.merged.security.auth.selectedType, ); if (err) { throw new Error(err); } await partialConfig.refreshAuth( settings.merged.security.auth.selectedType, ); } else if (!partialConfig.isInteractive()) { const authType = await validateNonInteractiveAuth( settings.merged.security.auth.selectedType, settings.merged.security.auth.useExternal, partialConfig, settings, ); await partialConfig.refreshAuth(authType); } } catch (err) { if (err instanceof ValidationCancelledError) { // User cancelled verification, exit immediately. await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } // If validation is required, we don't treat it as a fatal failure. // We allow the app to start, and the React-based ValidationDialog // will handle it. if (!(err instanceof ValidationRequiredError)) { debugLogger.error('Error authenticating:', err); initialAuthFailed = true; } } } const remoteAdminSettings = partialConfig.getRemoteAdminSettings(); // Set remote admin settings if returned from CCPA. if (remoteAdminSettings) { settings.setRemoteAdminSettings(remoteAdminSettings); } // Run deferred command now that we have admin settings. await runDeferredCommand(settings.merged); // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { const memoryArgs = settings.merged.advanced.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : []; const sandboxConfig = await loadSandboxConfig(settings.merged, argv); // We intentionally omit the list of extensions here because extensions // should not impact auth or setting up the sandbox. // TODO(jacobr): refactor loadCliConfig so there is a minimal version // that only initializes enough config to enable refreshAuth or find // another way to decouple refreshAuth from requiring a config. if (sandboxConfig) { if (initialAuthFailed) { await runExitCleanup(); process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); } let stdinData = ''; if (!process.stdin.isTTY) { stdinData = await readStdin(); } // This function is a copy of the one from sandbox.ts // It is moved here to decouple sandbox.ts from the CLI's argument structure. const injectStdinIntoArgs = ( args: string[], stdinData?: string, ): string[] => { const finalArgs = [...args]; if (stdinData) { const promptIndex = finalArgs.findIndex( (arg) => arg === '--prompt' || arg === '-p', ); if (promptIndex > -1 && finalArgs.length > promptIndex + 1) { // If there's a prompt argument, prepend stdin to it finalArgs[promptIndex + 1] = `${stdinData}\n\n${finalArgs[promptIndex + 1]}`; } else { // If there's no prompt argument, add stdin as the prompt finalArgs.push('--prompt', stdinData); } } return finalArgs; }; const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData); await relaunchOnExitCode(() => start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), ); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } else { // Relaunch app so we always have a child process that can be internally // restarted if needed. await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings); } } // We are now past the logic handling potentially launching a child process // to run Gemini CLI. It is now safe to perform expensive initialization that // may have side effects. { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, }); loadConfigHandle?.end(); // Initialize storage immediately after loading config to ensure that // storage-related operations (like listing or resuming sessions) have // access to the project identifier. await config.storage.initialize(); adminControlsListner.setConfig(config); if (config.isInteractive() && settings.merged.general.devtools) { const { setupInitialActivityLogger } = await import( './utils/devtoolsService.js' ); await setupInitialActivityLogger(config); } // Register config for telemetry shutdown // This ensures telemetry (including SessionEnd hooks) is properly flushed on exit registerTelemetryConfig(config); const policyEngine = config.getPolicyEngine(); const messageBus = config.getMessageBus(); createPolicyUpdater(policyEngine, messageBus, config.storage); // Register SessionEnd hook to fire on graceful exit // This runs before telemetry shutdown in runExitCleanup() registerCleanup(async () => { await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); }); // Cleanup sessions after config initialization try { await cleanupExpiredSessions(config, settings.merged); } catch (e) { debugLogger.error('Failed to cleanup expired sessions:', e); } if (config.getListExtensions()) { debugLogger.log('Installed extensions:'); for (const extension of config.getExtensions()) { debugLogger.log(`- ${extension.name}`); } await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } // Handle --list-sessions flag if (config.getListSessions()) { // Attempt auth for summary generation (gracefully skips if not configured) const authType = settings.merged.security.auth.selectedType; if (authType) { try { await config.refreshAuth(authType); } catch (e) { // Auth failed - continue without summary generation capability debugLogger.debug( 'Auth failed for --list-sessions, summaries may not be generated:', e, ); } } await listSessions(config); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } // Handle --delete-session flag const sessionToDelete = config.getDeleteSession(); if (sessionToDelete) { await deleteSession(config, sessionToDelete); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } const wasRaw = process.stdin.isRaw; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { // Set this as early as possible to avoid spurious characters from // input showing up in the output. process.stdin.setRawMode(true); // This cleanup isn't strictly needed but may help in certain situations. registerSyncCleanup(() => { process.stdin.setRawMode(wasRaw); }); } await setupTerminalAndTheme(config, settings); const initAppHandle = startupProfiler.start('initialize_app'); const initializationResult = await initializeApp(config, settings); initAppHandle?.end(); if ( settings.merged.security.auth.selectedType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { // Do oauth before app renders to make copying the link possible. await getOauthClient(settings.merged.security.auth.selectedType, config); } if (config.getAcpMode()) { return runAcpClient(config, settings, argv); } let input = config.getQuestion(); const useAlternateBuffer = shouldEnterAlternateScreen( isAlternateBufferEnabled(config), config.getScreenReader(), ); const rawStartupWarnings = await getStartupWarnings(); const startupWarnings: StartupWarning[] = [ ...rawStartupWarnings.map((message) => ({ id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, message, priority: WarningPriority.High, })), ...(await getUserStartupWarnings(settings.merged, undefined, { isAlternateBuffer: useAlternateBuffer, })), ]; // Handle --resume flag let resumedSessionData: ResumedSessionData | undefined = undefined; if (argv.resume) { const sessionSelector = new SessionSelector(config); try { const result = await sessionSelector.resolveSession(argv.resume); resumedSessionData = { conversation: result.sessionData, filePath: result.sessionPath, }; // Use the existing session ID to continue recording to the same session config.setSessionId(resumedSessionData.conversation.sessionId); } catch (error) { if ( error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND' ) { // No sessions to resume — start a fresh session with a warning startupWarnings.push({ id: 'resume-no-sessions', message: error.message, priority: WarningPriority.High, }); } else { coreEvents.emitFeedback( 'error', `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, ); await runExitCleanup(); process.exit(ExitCodes.FATAL_INPUT_ERROR); } } } cliStartupHandle?.end(); // Render UI, passing necessary config values. Check that there is no command line question. if (config.isInteractive()) { await startInteractiveUI( config, settings, startupWarnings, process.cwd(), resumedSessionData, initializationResult, ); return; } await config.initialize(); startupProfiler.flush(config); // If not a TTY, read from stdin // This is for cases where the user pipes input directly into the command let stdinData: string | undefined = undefined; if (!process.stdin.isTTY) { stdinData = await readStdin(); if (stdinData) { input = input ? `${stdinData}\n\n${input}` : stdinData; } } // Fire SessionStart hook through MessageBus (only if hooks are enabled) // Must be called AFTER config.initialize() to ensure HookRegistry is loaded const sessionStartSource = resumedSessionData ? SessionStartSource.Resume : SessionStartSource.Startup; const hookSystem = config?.getHookSystem(); if (hookSystem) { const result = await hookSystem.fireSessionStartEvent(sessionStartSource); if (result) { if (result.systemMessage) { writeToStderr(result.systemMessage + '\n'); } const additionalContext = result.getAdditionalContext(); if (additionalContext) { // Prepend context to input (System Context -> Stdin -> Question) const wrappedContext = `${additionalContext}`; input = input ? `${wrappedContext}\n\n${input}` : wrappedContext; } } } // Register SessionEnd hook for graceful exit registerCleanup(async () => { await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); }); if (!input) { debugLogger.error( `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, ); await runExitCleanup(); process.exit(ExitCodes.FATAL_INPUT_ERROR); } const prompt_id = sessionId; logUserPrompt( config, new UserPromptEvent( input.length, prompt_id, config.getContentGeneratorConfig()?.authType, input, ), ); const authType = await validateNonInteractiveAuth( settings.merged.security.auth.selectedType, settings.merged.security.auth.useExternal, config, settings, ); await config.refreshAuth(authType); if (config.getDebugMode()) { debugLogger.log('Session ID: %s', sessionId); } initializeOutputListenersAndFlush(); await runNonInteractive({ config, settings, input, prompt_id, resumedSessionData, }); // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); process.exit(ExitCodes.SUCCESS); } } export function initializeOutputListenersAndFlush() { // If there are no listeners for output, make sure we flush so output is not // lost. if (coreEvents.listenerCount(CoreEvent.Output) === 0) { // In non-interactive mode, ensure we drain any buffered output or logs to stderr coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => { if (payload.isStderr) { writeToStderr(payload.chunk, payload.encoding); } else { writeToStdout(payload.chunk, payload.encoding); } }); if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) { coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => { if (payload.type === 'error' || payload.type === 'warn') { writeToStderr(payload.content); } else { writeToStdout(payload.content); } }); } if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) { coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => { if (payload.severity === 'error' || payload.severity === 'warning') { writeToStderr(payload.message); } else { writeToStdout(payload.message); } }); } } coreEvents.drainBacklogs(); } function setupAdminControlsListener() { let pendingSettings: AdminControlsSettings | undefined; let config: Config | undefined; const messageHandler = (msg: unknown) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const message = msg as { type?: string; settings?: AdminControlsSettings; }; if (message?.type === 'admin-settings' && message.settings) { if (config) { config.setRemoteAdminSettings(message.settings); } else { pendingSettings = message.settings; } } }; process.on('message', messageHandler); return { setConfig: (newConfig: Config) => { config = newConfig; if (pendingSettings) { config.setRemoteAdminSettings(pendingSettings); } }, cleanup: () => { process.off('message', messageHandler); }, }; } ================================================ FILE: packages/cli/src/gemini_cleanup.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { main } from './gemini.js'; import { debugLogger, type Config } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, writeToStdout: vi.fn(), patchStdio: vi.fn(() => () => {}), createWorkingStdio: vi.fn(() => ({ stdout: { write: vi.fn(), columns: 80, rows: 24, on: vi.fn(), removeListener: vi.fn(), }, stderr: { write: vi.fn() }, })), enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), enterAlternateScreen: vi.fn(), disableLineWrapping: vi.fn(), ProjectRegistry: vi.fn().mockImplementation(() => ({ initialize: vi.fn(), getShortId: vi.fn().mockReturnValue('project-slug'), })), }; }); vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, render: vi.fn(() => ({ unmount: vi.fn(), rerender: vi.fn(), cleanup: vi.fn(), waitUntilExit: vi.fn(), })), }; }); vi.mock('./config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn().mockReturnValue({ merged: { advanced: {}, security: { auth: {} }, ui: {} }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), errors: [], }), }; }); vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockResolvedValue({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, storage: { initialize: vi.fn().mockResolvedValue(undefined) }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ readPackageUp: vi.fn().mockResolvedValue({ packageJson: { name: 'test-pkg', version: 'test-version' }, path: '/fake/path/package.json', }), })); vi.mock('update-notifier', () => ({ default: vi.fn(() => ({ notify: vi.fn() })), })); vi.mock('./utils/events.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, appEvents: { emit: vi.fn() } }; }); vi.mock('./utils/sandbox.js', () => ({ sandbox_command: vi.fn(() => ''), start_sandbox: vi.fn(() => Promise.resolve()), })); vi.mock('./utils/relaunch.js', () => ({ relaunchAppInChildProcess: vi.fn(), relaunchOnExitCode: vi.fn(), })); vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn(), })); vi.mock('./ui/utils/mouse.js', () => ({ enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), parseMouseEvent: vi.fn(), isIncompleteMouseSequence: vi.fn(), })); vi.mock('./validateNonInterActiveAuth.js', () => ({ validateNonInteractiveAuth: vi.fn().mockResolvedValue({}), })); vi.mock('./core/initializer.js', () => ({ initializeApp: vi.fn().mockResolvedValue({ authError: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, }), })); vi.mock('./nonInteractiveCli.js', () => ({ runNonInteractive: vi.fn().mockResolvedValue(undefined), })); vi.mock('./utils/cleanup.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, cleanupCheckpoints: vi.fn().mockResolvedValue(undefined), registerCleanup: vi.fn(), registerSyncCleanup: vi.fn(), registerTelemetryConfig: vi.fn(), runExitCleanup: vi.fn().mockResolvedValue(undefined), }; }); vi.mock('./zed-integration/zedIntegration.js', () => ({ runZedIntegration: vi.fn().mockResolvedValue(undefined), })); vi.mock('./utils/readStdin.js', () => ({ readStdin: vi.fn().mockResolvedValue(''), })); const { cleanupMockState } = vi.hoisted(() => ({ cleanupMockState: { shouldThrow: false, called: false }, })); // Mock sessionCleanup.js at the top level vi.mock('./utils/sessionCleanup.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, cleanupExpiredSessions: async () => { cleanupMockState.called = true; if (cleanupMockState.shouldThrow) { throw new Error('Cleanup failed'); } }, }; }); describe('gemini.tsx main function cleanup', () => { beforeEach(() => { vi.clearAllMocks(); process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; }); afterEach(() => { delete process.env['GEMINI_CLI_NO_RELAUNCH']; vi.restoreAllMocks(); }); it.skip('should log error when cleanupExpiredSessions fails', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' ); const { loadSettings } = await import('./config/settings.js'); cleanupMockState.shouldThrow = true; cleanupMockState.called = false; const debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); vi.mocked(loadSettings).mockReturnValue({ merged: { advanced: {}, security: { auth: {} }, ui: {} }, workspace: { settings: {} }, setValue: vi.fn(), forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), errors: [], } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue({ isInteractive: vi.fn(() => false), getQuestion: vi.fn(() => 'test'), getSandbox: vi.fn(() => false), getDebugMode: vi.fn(() => false), getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: vi.fn(() => false), getHookSystem: () => undefined, initialize: vi.fn(), storage: { initialize: vi.fn().mockResolvedValue(undefined) }, getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), getMcpClientManager: vi.fn(), getIdeMode: vi.fn(() => false), getAcpMode: vi.fn(() => true), getScreenReader: vi.fn(() => false), getGeminiMdFileCount: vi.fn(() => 0), getProjectRoot: vi.fn(() => '/'), getListExtensions: vi.fn(() => false), getListSessions: vi.fn(() => false), getDeleteSession: vi.fn(() => undefined), getToolRegistry: vi.fn(), getExtensions: vi.fn(() => []), getModel: vi.fn(() => 'gemini-pro'), getEmbeddingModel: vi.fn(() => 'embedding-001'), getApprovalMode: vi.fn(() => 'default'), getCoreTools: vi.fn(() => []), getTelemetryEnabled: vi.fn(() => false), getTelemetryLogPromptsEnabled: vi.fn(() => false), getFileFilteringRespectGitIgnore: vi.fn(() => true), getOutputFormat: vi.fn(() => 'text'), getUsageStatisticsEnabled: vi.fn(() => false), setTerminalBackground: vi.fn(), refreshAuth: vi.fn(), getRemoteAdminSettings: vi.fn(() => undefined), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any await main(); expect(cleanupMockState.called).toBe(true); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'Failed to cleanup expired sessions:', expect.objectContaining({ message: 'Cleanup failed' }), ); }); }); ================================================ FILE: packages/cli/src/integration-tests/modelSteering.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, afterEach } from 'vitest'; import { AppRig } from '../test-utils/AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { PolicyDecision } from '@google/gemini-cli-core'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('Model Steering Integration', () => { let rig: AppRig | undefined; afterEach(async () => { await rig?.unmount(); }); it('should steer the model using a hint during a tool turn', async () => { const fakeResponsesPath = path.join( __dirname, '../test-utils/fixtures/steering.responses', ); rig = new AppRig({ fakeResponsesPath, configOverrides: { modelSteering: true }, }); await rig.initialize(); await rig.render(); await rig.waitForIdle(); rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); rig.setToolPolicy('read_file', PolicyDecision.ASK_USER); rig.setMockCommands([ { command: /list_directory/, result: { output: 'file1.txt\nfile2.js\nfile3.md', exitCode: 0, }, }, { command: /read_file file1.txt/, result: { output: 'This is file1.txt content.', exitCode: 0, }, }, ]); // Start a long task await rig.type('Start long task'); await rig.pressEnter(); // Wait for the model to call 'list_directory' (Confirming state) await rig.waitForOutput('ReadFolder'); // Injected a hint while the model is in a tool turn await rig.addUserHint('focus on .txt'); // Resolve list_directory (Proceed) await rig.resolveTool('ReadFolder'); // Then it should proceed with the next action await rig.waitForOutput( /Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/, ); await rig.waitForOutput('ReadFile'); // Resolve read_file (Proceed) await rig.resolveTool('ReadFile'); // Wait for final completion await rig.waitForOutput('Task complete.'); }); }); ================================================ FILE: packages/cli/src/interactiveCli.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { render } from 'ink'; import { basename } from 'node:path'; import { AppContainer } from './ui/AppContainer.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { registerCleanup, setupTtyCheck } from './utils/cleanup.js'; import { type StartupWarning, type Config, type ResumedSessionData, coreEvents, createWorkingStdio, disableMouseEvents, enableMouseEvents, disableLineWrapping, enableLineWrapping, shouldEnterAlternateScreen, recordSlowRender, writeToStdout, getVersion, debugLogger, } from '@google/gemini-cli-core'; import type { InitializationResult } from './core/initializer.js'; import type { LoadedSettings } from './config/settings.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js'; import { StreamingState } from './ui/types.js'; import { computeTerminalTitle } from './utils/windowTitle.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js'; import { loadKeyMatchers } from './ui/key/keyMatchers.js'; import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { profiler } from './ui/components/DebugProfiler.js'; const SLOW_RENDER_MS = 200; export async function startInteractiveUI( config: Config, settings: LoadedSettings, startupWarnings: StartupWarning[], workspaceRoot: string = process.cwd(), resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { // Never enter Ink alternate buffer mode when screen reader mode is enabled // as there is no benefit of alternate buffer mode when using a screen reader // and the Ink alternate buffer mode requires line wrapping harmful to // screen readers. const useAlternateBuffer = shouldEnterAlternateScreen( isAlternateBufferEnabled(config), config.getScreenReader(), ); const mouseEventsEnabled = useAlternateBuffer; if (mouseEventsEnabled) { enableMouseEvents(); registerCleanup(() => { disableMouseEvents(); }); } const { matchers, errors } = await loadKeyMatchers(); errors.forEach((error) => { coreEvents.emitFeedback('warning', error); }); const version = await getVersion(); setWindowTitle(basename(workspaceRoot), settings); const consolePatcher = new ConsolePatcher({ onNewMessage: (msg) => { coreEvents.emitConsoleLog(msg.type, msg.content); }, debugMode: config.getDebugMode(), }); consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); const isShpool = !!process.env['SHPOOL_SESSION_NAME']; // Create wrapper component to use hooks inside render const AppWrapper = () => { useKittyKeyboardProtocol(); return ( ); }; if (isShpool) { // Wait a moment for shpool to stabilize terminal size and state. await new Promise((resolve) => setTimeout(resolve, 100)); } const instance = render( process.env['DEBUG'] ? ( ) : ( ), { stdout: inkStdout, stderr: inkStderr, stdin: process.stdin, exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader(), onRender: ({ renderTime }: { renderTime: number }) => { if (renderTime > SLOW_RENDER_MS) { recordSlowRender(config, renderTime); } profiler.reportFrameRendered(); }, patchConsole: false, alternateBuffer: useAlternateBuffer, incrementalRendering: settings.merged.ui.incrementalRendering !== false && useAlternateBuffer && !isShpool, }, ); if (useAlternateBuffer) { disableLineWrapping(); registerCleanup(() => { enableLineWrapping(); }); } checkForUpdates(settings) .then((info) => { handleAutoUpdate(info, settings, config.getProjectRoot()); }) .catch((err) => { // Silently ignore update check errors. if (config.getDebugMode()) { debugLogger.warn('Update check failed:', err); } }); registerCleanup(() => instance.unmount()); registerCleanup(setupTtyCheck()); } function setWindowTitle(title: string, settings: LoadedSettings) { if (!settings.merged.ui.hideWindowTitle) { // Initial state before React loop starts const windowTitle = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, isSilentWorking: false, folderName: title, showThoughts: !!settings.merged.ui.showStatusInTitle, useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); writeToStdout(`\x1b]0;${windowTitle}\x07`); process.on('exit', () => { writeToStdout(`\x1b]0;\x07`); }); } } ================================================ FILE: packages/cli/src/nonInteractiveCli.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Config, ToolRegistry, ServerGeminiStreamEvent, SessionMetrics, AnyDeclarativeTool, AnyToolInvocation, UserFeedbackPayload, } from '@google/gemini-cli-core'; import { ToolErrorType, GeminiEventType, OutputFormat, uiTelemetryService, FatalInputError, CoreEvent, CoreToolCallStatus, } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; import { describe, it, expect, beforeEach, afterEach, vi, type Mock, type MockInstance, } from 'vitest'; import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn()); vi.mock('./utils/devtoolsService.js', () => ({ setupInitialActivityLogger: mockSetupInitialActivityLogger, })); const mockCoreEvents = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), emit: vi.fn(), emitConsoleLog: vi.fn(), emitFeedback: vi.fn(), drainBacklogs: vi.fn(), })); const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); class MockChatRecordingService { initialize = vi.fn(); recordMessage = vi.fn(); recordMessageTokens = vi.fn(); recordToolCalls = vi.fn(); } return { ...original, Scheduler: class { schedule = mockSchedulerSchedule; cancelAll = vi.fn(); }, isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), ChatRecordingService: MockChatRecordingService, uiTelemetryService: { getMetrics: vi.fn(), }, coreEvents: mockCoreEvents, createWorkingStdio: vi.fn(() => ({ stdout: process.stdout, stderr: process.stderr, })), }; }); const mockGetCommands = vi.hoisted(() => vi.fn()); const mockCommandServiceCreate = vi.hoisted(() => vi.fn()); vi.mock('./services/CommandService.js', () => ({ CommandService: { create: mockCommandServiceCreate, }, })); vi.mock('./services/FileCommandLoader.js'); vi.mock('./services/McpPromptLoader.js'); vi.mock('./services/BuiltinCommandLoader.js'); describe('runNonInteractive', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockToolRegistry: ToolRegistry; let consoleErrorSpy: MockInstance; let processStdoutSpy: MockInstance; let processStderrSpy: MockInstance; let mockGeminiClient: { sendMessageStream: Mock; resumeChat: Mock; getChatRecordingService: Mock; }; const MOCK_SESSION_METRICS: SessionMetrics = { models: {}, tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, totalDurationMs: 0, totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0, }, byName: {}, }, files: { totalLinesAdded: 0, totalLinesRemoved: 0, }, }; beforeEach(async () => { mockSchedulerSchedule.mockReset(); mockCommandServiceCreate.mockResolvedValue({ getCommands: mockGetCommands, }); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout); processStderrSpy = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); }); mockToolRegistry = { getTool: vi.fn(), getFunctionDeclarations: vi.fn().mockReturnValue([]), } as unknown as ToolRegistry; mockGeminiClient = { sendMessageStream: vi.fn(), resumeChat: vi.fn().mockResolvedValue(undefined), getChatRecordingService: vi.fn(() => ({ initialize: vi.fn(), recordMessage: vi.fn(), recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), })), }; mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), getMessageBus: vi.fn().mockReturnValue({ subscribe: vi.fn(), unsubscribe: vi.fn(), publish: vi.fn(), }), getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMaxSessionTurns: vi.fn().mockReturnValue(10), getSessionId: vi.fn().mockReturnValue('test-session-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'), }, getIdeMode: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getDebugMode: vi.fn().mockReturnValue(false), getOutputFormat: vi.fn().mockReturnValue('text'), getModel: vi.fn().mockReturnValue('test-model'), getFolderTrust: vi.fn().mockReturnValue(false), isTrustedFolder: vi.fn().mockReturnValue(false), getRawOutput: vi.fn().mockReturnValue(false), getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), } as unknown as Config; mockSettings = { system: { path: '', settings: {} }, systemDefaults: { path: '', settings: {} }, user: { path: '', settings: {} }, workspace: { path: '', settings: {} }, errors: [], setValue: vi.fn(), merged: { security: { auth: { enforcedType: undefined, }, }, }, isTrusted: true, migratedInMemoryScopes: new Set(), forScope: vi.fn(), computeMergedSettings: vi.fn(), } as unknown as LoadedSettings; const { handleAtCommand } = await import( './ui/hooks/atCommandProcessor.js' ); vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({ processedQuery: [{ text: query }], })); }); afterEach(() => { vi.restoreAllMocks(); }); async function* createStreamFromEvents( events: ServerGeminiStreamEvent[], ): AsyncGenerator { for (const event of events) { yield event; } } const getWrittenOutput = () => processStdoutSpy.mock.calls.map((c) => c[0]).join(''); it('should process input and write text output', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Hello' }, { type: GeminiEventType.Content, value: ' World' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-1', }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', undefined, false, 'Test input', ); expect(getWrittenOutput()).toBe('Hello World\n'); // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts // so we no longer expect shutdownTelemetry to be called directly here }); it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => { vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl'); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-activity-logger', }); expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig); vi.unstubAllEnvs(); }); it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => { vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', ''); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-activity-logger-off', }); expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); it('should handle a single tool call and respond', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-2', }, }; const toolResponse: Part[] = [{ text: 'Tool response' }]; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-2', }, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: toolResponse, callId: 'tool-1', error: undefined, errorType: undefined, contentLength: undefined, }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; const secondCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Final answer' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Use a tool', prompt_id: 'prompt-id-2', }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockSchedulerSchedule).toHaveBeenCalledWith( [expect.objectContaining({ name: 'testTool' })], expect.any(AbortSignal), ); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', undefined, false, undefined, ); expect(getWrittenOutput()).toBe('Final answer\n'); }); it('should write a single newline between sequential text outputs from the model', async () => { // This test simulates a multi-turn conversation to ensure that a single newline // is printed between each block of text output from the model. // 1. Define the tool requests that the model will ask the CLI to run. const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'mock-tool', name: 'mockTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-multi', }, }; // 2. Mock the execution of the tools. We just need them to succeed. mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: toolCallEvent.value, // This is generic enough for both calls tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: [], callId: 'mock-tool', }, }, ]); // 3. Define the sequence of events streamed from the mock model. // Turn 1: Model outputs text, then requests a tool call. const modelTurn1: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Use mock tool' }, toolCallEvent, ]; // Turn 2: Model outputs more text, then requests another tool call. const modelTurn2: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Use mock tool again' }, toolCallEvent, ]; // Turn 3: Model outputs a final answer. const modelTurn3: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Finished.' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(modelTurn1)) .mockReturnValueOnce(createStreamFromEvents(modelTurn2)) .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); // 4. Run the command. await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Use mock tool multiple times', prompt_id: 'prompt-id-multi', }); // 5. Verify the output. // The rendered output should contain the text from each turn, separated by a // single newline, with a final newline at the end. expect(getWrittenOutput()).toMatchSnapshot(); // Also verify the tools were called as expected. expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2); }); it('should handle error during tool execution and should send error back to the model', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'errorTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-3', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Error, request: { callId: 'tool-1', name: 'errorTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-3', }, tool: {} as AnyDeclarativeTool, response: { callId: 'tool-1', error: new Error('Execution failed'), errorType: ToolErrorType.EXECUTION_FAILED, responseParts: [ { functionResponse: { name: 'errorTool', response: { output: 'Error: Execution failed', }, }, }, ], resultDisplay: 'Execution failed', contentLength: undefined, }, }, ]); const finalResponse: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Sorry, let me try again.', }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Trigger tool error', prompt_id: 'prompt-id-3', }); expect(mockSchedulerSchedule).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool errorTool: Execution failed', ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [ { functionResponse: { name: 'errorTool', response: { output: 'Error: Execution failed', }, }, }, ], expect.any(AbortSignal), 'prompt-id-3', undefined, false, undefined, ); expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); }); it('should exit with error if sendMessageStream throws initially', async () => { const apiError = new Error('API connection failed'); mockGeminiClient.sendMessageStream.mockImplementation(() => { throw apiError; }); await expect( runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Initial fail', prompt_id: 'prompt-id-4', }), ).rejects.toThrow(apiError); }); it('should not exit if a tool is not found, and should send error back to model', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'nonexistentTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-5', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Error, request: { callId: 'tool-1', name: 'nonexistentTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-5', }, response: { callId: 'tool-1', error: new Error('Tool "nonexistentTool" not found in registry.'), resultDisplay: 'Tool "nonexistentTool" not found in registry.', responseParts: [], errorType: undefined, contentLength: undefined, }, }, ]); const finalResponse: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: "Sorry, I can't find that tool.", }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Trigger tool not found', prompt_id: 'prompt-id-5', }); expect(mockSchedulerSchedule).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n"); }); it('should exit when max session turns are exceeded', async () => { vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); await expect( runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Trigger loop', prompt_id: 'prompt-id-6', }), ).rejects.toThrow('process.exit(53) called'); }); it('should preprocess @include commands before sending to the model', async () => { // 1. Mock the imported atCommandProcessor const { handleAtCommand } = await import( './ui/hooks/atCommandProcessor.js' ); const mockHandleAtCommand = vi.mocked(handleAtCommand); // 2. Define the raw input and the expected processed output const rawInput = 'Summarize @file.txt'; const processedParts: Part[] = [ { text: 'Summarize @file.txt' }, { text: '\n--- Content from referenced files ---\n' }, { text: 'This is the content of the file.' }, { text: '\n--- End of content ---' }, ]; // 3. Setup the mock to return the processed parts mockHandleAtCommand.mockResolvedValue({ processedQuery: processedParts, }); // Mock a simple stream response from the Gemini client const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Summary complete.' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); // 4. Run the non-interactive mode with the raw input await runNonInteractive({ config: mockConfig, settings: mockSettings, input: rawInput, prompt_id: 'prompt-id-7', }); // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( processedParts, expect.any(AbortSignal), 'prompt-id-7', undefined, false, rawInput, ); // 6. Assert the final output is correct expect(getWrittenOutput()).toBe('Summary complete.\n'); }); it('should process input and write JSON output with stats', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Hello World' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-1', }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', undefined, false, 'Test input', ); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( { session_id: 'test-session-id', response: 'Hello World', stats: MOCK_SESSION_METRICS, }, null, 2, ), ); }); it('should write JSON output with stats for tool-only commands (no text response)', async () => { // Test the scenario where a command completes successfully with only tool calls // but no text response - this would have caught the original bug const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-tool-only', }, }; const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-tool-only', }, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: toolResponse, callId: 'tool-1', error: undefined, errorType: undefined, contentLength: undefined, }, }, ]); // First call returns only tool call, no content const firstCallEvents: ServerGeminiStreamEvent[] = [ toolCallEvent, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; // Second call returns no content (tool-only completion) const secondCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Execute tool only', prompt_id: 'prompt-id-tool-only', }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockSchedulerSchedule).toHaveBeenCalledWith( [expect.objectContaining({ name: 'testTool' })], expect.any(AbortSignal), ); // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( { session_id: 'test-session-id', response: '', stats: MOCK_SESSION_METRICS, }, null, 2, ), ); }); it('should write JSON output with stats for empty response commands', async () => { // Test the scenario where a command completes but produces no content at all const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Empty response test', prompt_id: 'prompt-id-empty', }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', undefined, false, 'Empty response test', ); // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( { session_id: 'test-session-id', response: '', stats: MOCK_SESSION_METRICS, }, null, 2, ), ); }); it('should handle errors in JSON format', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); const testError = new Error('Invalid input provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { throw testError; }); let thrownError: Error | null = null; try { await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-error', }); // Should not reach here expect.fail('Expected process.exit to be called'); } catch (error) { thrownError = error as Error; } // Should throw because of mocked process.exit expect(thrownError?.message).toBe('process.exit(1) called'); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', JSON.stringify( { session_id: 'test-session-id', error: { type: 'Error', message: 'Invalid input provided', code: 1, }, }, null, 2, ), ); }); it('should handle FatalInputError with custom exit code in JSON format', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); const fatalError = new FatalInputError('Invalid command syntax provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { throw fatalError; }); let thrownError: Error | null = null; try { await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Invalid syntax', prompt_id: 'prompt-id-fatal', }); // Should not reach here expect.fail('Expected process.exit to be called'); } catch (error) { thrownError = error as Error; } // Should throw because of mocked process.exit with custom exit code expect(thrownError?.message).toBe('process.exit(42) called'); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', JSON.stringify( { session_id: 'test-session-id', error: { type: 'FatalInputError', message: 'Invalid command syntax provided', code: 42, }, }, null, 2, ), ); }); it('should execute a slash command that returns a prompt', async () => { const mockCommand = { name: 'testcommand', description: 'a test command', action: vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'Prompt from command' }], }), }; mockGetCommands.mockReturnValue([mockCommand]); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Response from command' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/testcommand', prompt_id: 'prompt-id-slash', }); // Ensure the prompt sent to the model is from the command, not the raw input expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', undefined, false, '/testcommand', ); expect(getWrittenOutput()).toBe('Response from command\n'); }); it('should handle slash commands', async () => { const nonInteractiveCliCommands = await import( './nonInteractiveCliCommands.js' ); const handleSlashCommandSpy = vi.spyOn( nonInteractiveCliCommands, 'handleSlashCommand', ); handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Response to slash command' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/help', prompt_id: 'prompt-id-slash', }); expect(handleSlashCommandSpy).toHaveBeenCalledWith( '/help', expect.any(AbortController), mockConfig, mockSettings, ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Slash command output' }], expect.any(AbortSignal), 'prompt-id-slash', undefined, false, '/help', ); expect(getWrittenOutput()).toBe('Response to slash command\n'); handleSlashCommandSpy.mockRestore(); }); it('should handle cancellation (Ctrl+C)', async () => { // Mock isTTY and setRawMode safely const originalIsTTY = process.stdin.isTTY; // eslint-disable-next-line @typescript-eslint/no-explicit-any const originalSetRawMode = (process.stdin as any).setRawMode; Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, }); if (!originalSetRawMode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).setRawMode = vi.fn(); } const stdinOnSpy = vi .spyOn(process.stdin, 'on') .mockImplementation(() => process.stdin); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( () => process.stdin, ); // Spy on handleCancellationError to verify it's called const errors = await import('./utils/errors.js'); const handleCancellationErrorSpy = vi .spyOn(errors, 'handleCancellationError') .mockImplementation(() => { throw new Error('Cancelled'); }); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Thinking...' }, ]; // Create a stream that responds to abortion mockGeminiClient.sendMessageStream.mockImplementation( (_messages, signal: AbortSignal) => (async function* () { yield events[0]; await new Promise((resolve, reject) => { const timeout = setTimeout(resolve, 1000); signal.addEventListener('abort', () => { clearTimeout(timeout); setTimeout(() => { reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError }, 300); }); }); })(), ); const runPromise = runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Long running query', prompt_id: 'prompt-id-cancel', }); // Wait a bit for setup to complete and listeners to be registered await new Promise((resolve) => setTimeout(resolve, 100)); // Find the keypress handler registered by runNonInteractive const keypressCall = stdinOnSpy.mock.calls.find( // eslint-disable-next-line @typescript-eslint/no-explicit-any (call) => (call[0] as any) === 'keypress', ); expect(keypressCall).toBeDefined(); const keypressHandler = keypressCall?.[1] as ( str: string, key: { name?: string; ctrl?: boolean }, ) => void; if (keypressHandler) { // Simulate Ctrl+C keypressHandler('\u0003', { ctrl: true, name: 'c' }); } // The promise should reject with 'Aborted' because our mock stream throws it, // and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw. // Wait, if handleError is called, we should check that. // But here we want to check if Ctrl+C works. // In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'. // nonInteractiveCli catches 'Aborted' and calls handleError. // If we want to test that handleCancellationError is called, we need the loop to detect abortion. // But our stream throws before the loop can detect it. // Let's just check that the promise rejects with 'Aborted' for now, // which proves the abortion signal reached the stream. await expect(runPromise).rejects.toThrow('Aborted'); expect( processStderrSpy.mock.calls.some( (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), ), ).toBe(true); handleCancellationErrorSpy.mockRestore(); // Restore original values Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true, }); if (originalSetRawMode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).setRawMode = originalSetRawMode; } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (process.stdin as any).setRawMode; } // Spies are automatically restored by vi.restoreAllMocks() in afterEach, // but we can also do it manually if needed. }); it('should throw FatalInputError if a command requires confirmation', async () => { const mockCommand = { name: 'confirm', description: 'a command that needs confirmation', action: vi.fn().mockResolvedValue({ type: 'confirm_shell_commands', commands: ['rm -rf /'], }), }; mockGetCommands.mockReturnValue([mockCommand]); await expect( runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/confirm', prompt_id: 'prompt-id-confirm', }), ).rejects.toThrow( 'Exiting due to a confirmation prompt requested by the command.', ); }); it('should treat an unknown slash command as a regular prompt', async () => { // No commands are mocked, so any slash command is "unknown" mockGetCommands.mockReturnValue([]); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Response to unknown' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/unknowncommand', prompt_id: 'prompt-id-unknown', }); // Ensure the raw input is sent to the model expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', undefined, false, '/unknowncommand', ); expect(getWrittenOutput()).toBe('Response to unknown\n'); }); it('should throw for unhandled command result types', async () => { const mockCommand = { name: 'noaction', description: 'unhandled type', action: vi.fn().mockResolvedValue({ type: 'unhandled', }), }; mockGetCommands.mockReturnValue([mockCommand]); await expect( runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/noaction', prompt_id: 'prompt-id-unhandled', }), ).rejects.toThrow( 'Exiting due to command result that is not supported in non-interactive mode.', ); }); it('should pass arguments to the slash command action', async () => { const mockAction = vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'Prompt from command' }], }); const mockCommand = { name: 'testargs', description: 'a test command', action: mockAction, }; mockGetCommands.mockReturnValue([mockCommand]); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Acknowledged' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/testargs arg1 arg2', prompt_id: 'prompt-id-args', }); expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); expect(getWrittenOutput()).toBe('Acknowledged\n'); }); it('should instantiate CommandService with correct loaders for slash commands', async () => { // This test indirectly checks that handleSlashCommand is using the right loaders. const { FileCommandLoader } = await import( './services/FileCommandLoader.js' ); const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); const { BuiltinCommandLoader } = await import( './services/BuiltinCommandLoader.js' ); mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Acknowledged' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: '/mycommand', prompt_id: 'prompt-id-loaders', }); // Check that loaders were instantiated with the config expect(FileCommandLoader).toHaveBeenCalledTimes(1); expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); expect(McpPromptLoader).toHaveBeenCalledTimes(1); expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); // Check that instances were passed to CommandService.create expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; expect(loadersArg).toHaveLength(3); expect(loadersArg[0]).toBe( vi.mocked(BuiltinCommandLoader).mock.instances[0], ); expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); }); it('should allow a normally-excluded tool when --allowed-tools is set', async () => { // By default, ShellTool is excluded in non-interactive mode. // This test ensures that --allowed-tools overrides this exclusion. vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ getTool: vi.fn().mockReturnValue({ name: 'ShellTool', description: 'A shell tool', run: vi.fn(), }), getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]), } as unknown as ToolRegistry); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-shell-1', name: 'ShellTool', args: { command: 'ls' }, isClientInitiated: false, prompt_id: 'prompt-id-allowed', }, }; const toolResponse: Part[] = [{ text: 'file.txt' }]; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: { callId: 'tool-shell-1', name: 'ShellTool', args: { command: 'ls' }, isClientInitiated: false, prompt_id: 'prompt-id-allowed', }, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: toolResponse, callId: 'tool-shell-1', error: undefined, errorType: undefined, contentLength: undefined, }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; const secondCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'file.txt' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'List the files', prompt_id: 'prompt-id-allowed', }); expect(mockSchedulerSchedule).toHaveBeenCalledWith( [expect.objectContaining({ name: 'ShellTool' })], expect.any(AbortSignal), ); expect(getWrittenOutput()).toBe('file.txt\n'); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on start', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-events', }); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); }); it('unsubscribes from UserFeedback on finish', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-events', }); expect(mockCoreEvents.off).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); }); it('logs to process.stderr when UserFeedback event is received', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-events', }); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( (call: unknown[]) => call[0] === CoreEvent.UserFeedback, )?.[1]; expect(handler).toBeDefined(); // Simulate an event const payload: UserFeedbackPayload = { severity: 'error', message: 'Test error message', }; handler(payload); expect(processStderrSpy).toHaveBeenCalledWith( '[ERROR] Test error message\n', ); }); it('logs optional error object to process.stderr in debug mode', async () => { vi.mocked(mockConfig.getDebugMode).mockReturnValue(true); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test', prompt_id: 'prompt-id-events', }); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( (call: unknown[]) => call[0] === CoreEvent.UserFeedback, )?.[1]; expect(handler).toBeDefined(); // Simulate an event with error object const errorObj = new Error('Original error'); // Mock stack for deterministic testing errorObj.stack = 'Error: Original error\n at test'; const payload: UserFeedbackPayload = { severity: 'warning', message: 'Test warning message', error: errorObj, }; handler(payload); expect(processStderrSpy).toHaveBeenCalledWith( '[WARNING] Test warning message\n', ); expect(processStderrSpy).toHaveBeenCalledWith( 'Error: Original error\n at test\n', ); }); }); it('should emit appropriate events for streaming JSON output', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, ); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-stream', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: [{ text: 'Tool response' }], callId: 'tool-1', error: undefined, errorType: undefined, contentLength: undefined, resultDisplay: 'Tool executed successfully', }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Thinking...' }, toolCallEvent, ]; const secondCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Final answer' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Stream test', prompt_id: 'prompt-id-stream', }); const output = getWrittenOutput(); const sanitizedOutput = output .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') .replace(/"duration_ms":\d+/g, '"duration_ms":'); expect(sanitizedOutput).toMatchSnapshot(); }); it('should handle EPIPE error gracefully', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Hello' }, { type: GeminiEventType.Content, value: ' World' }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); // Mock process.exit to track calls without throwing vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never); // Simulate EPIPE error on stdout const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find( (call) => call[0] === 'error', )?.[1]; if (stdoutErrorCallback) { stdoutErrorCallback({ code: 'EPIPE' }); } await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'EPIPE test', prompt_id: 'prompt-id-epipe', }); // Since EPIPE is simulated, it might exit early or continue depending on timing, // but our main goal is to verify the handler is registered and handles EPIPE. expect(process.stdout.on).toHaveBeenCalledWith( 'error', expect.any(Function), ); }); it('should resume chat when resumedSessionData is provided', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Resumed' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); const resumedSessionData = { conversation: { sessionId: 'resumed-session-id', messages: [ { role: 'user', parts: [{ text: 'Previous message' }] }, ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), firstUserMessage: 'Previous message', projectHash: 'test-hash', }, filePath: '/path/to/session.json', }; await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Continue', prompt_id: 'prompt-id-resume', resumedSessionData, }); expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( expect.any(Array), resumedSessionData, ); expect(getWrittenOutput()).toBe('Resumed\n'); }); it.each([ { name: 'loop detected', events: [ { type: GeminiEventType.LoopDetected }, ] as ServerGeminiStreamEvent[], input: 'Loop test', promptId: 'prompt-id-loop', }, { name: 'max session turns', events: [ { type: GeminiEventType.MaxSessionTurns }, ] as ServerGeminiStreamEvent[], input: 'Max turns test', promptId: 'prompt-id-max-turns', }, ])( 'should emit appropriate error event in streaming JSON mode: $name', async ({ events, input, promptId }) => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, ); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); const streamEvents: ServerGeminiStreamEvent[] = [ ...events, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(streamEvents), ); try { await runNonInteractive({ config: mockConfig, settings: mockSettings, input, prompt_id: promptId, }); } catch (_error) { // Expected exit } const output = getWrittenOutput(); const sanitizedOutput = output .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') .replace(/"duration_ms":\d+/g, '"duration_ms":'); expect(sanitizedOutput).toMatchSnapshot(); }, ); it('should log error when tool recording fails', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-tool-error', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Success, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { responseParts: [], callId: 'tool-1', error: undefined, errorType: undefined, contentLength: undefined, }, }, ]); const events: ServerGeminiStreamEvent[] = [ toolCallEvent, { type: GeminiEventType.Content, value: 'Done' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(events)) .mockReturnValueOnce( createStreamFromEvents([ { type: GeminiEventType.Content, value: 'Done' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]), ); // Mock getChat to throw when recording tool calls const mockChat = { recordCompletedToolCalls: vi.fn().mockImplementation(() => { throw new Error('Recording failed'); }), }; // @ts-expect-error - Mocking internal structure mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat); // @ts-expect-error - Mocking internal structure mockGeminiClient.getCurrentSequenceModel = vi .fn() .mockReturnValue('model-1'); // Mock debugLogger.error const { debugLogger } = await import('@google/gemini-cli-core'); const debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Tool recording error test', prompt_id: 'prompt-id-tool-error', }); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining( 'Error recording completed tool call information: Error: Recording failed', ), ); expect(getWrittenOutput()).toContain('Done'); }); it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'stop-call', name: 'stopTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-stop', }, }; // Mock tool execution returning STOP_EXECUTION mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Error, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { callId: 'stop-call', responseParts: [{ text: 'error occurred' }], errorType: ToolErrorType.STOP_EXECUTION, error: new Error('Stop reason from hook'), resultDisplay: undefined, }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Executing tool...' }, toolCallEvent, ]; // Setup the mock to return events for the first call. // We expect the loop to terminate after the tool execution. // If it doesn't, it might call sendMessageStream again, which we'll assert against. mockGeminiClient.sendMessageStream .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents([])); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Run stop tool', prompt_id: 'prompt-id-stop', }); expect(mockSchedulerSchedule).toHaveBeenCalled(); // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input). expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); expect(processStderrSpy).toHaveBeenCalledWith( 'Agent execution stopped: Stop reason from hook\n', ); }); it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'stop-call', name: 'stopTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-stop-json', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Error, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { callId: 'stop-call', responseParts: [{ text: 'error occurred' }], errorType: ToolErrorType.STOP_EXECUTION, error: new Error('Stop reason'), resultDisplay: undefined, }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Partial content' }, toolCallEvent, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(firstCallEvents), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Run stop tool', prompt_id: 'prompt-id-stop-json', }); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( { session_id: 'test-session-id', response: 'Partial content', stats: MOCK_SESSION_METRICS, }, null, 2, ), ); }); it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, ); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'stop-call', name: 'stopTool', args: {}, isClientInitiated: false, prompt_id: 'prompt-id-stop-stream', }, }; mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Error, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { callId: 'stop-call', responseParts: [{ text: 'error occurred' }], errorType: ToolErrorType.STOP_EXECUTION, error: new Error('Stop reason'), resultDisplay: undefined, }, }, ]); const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(firstCallEvents), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Run stop tool', prompt_id: 'prompt-id-stop-stream', }); const output = getWrittenOutput(); expect(output).toContain('"type":"result"'); expect(output).toContain('"status":"success"'); }); describe('Agent Execution Events', () => { it('should handle AgentExecutionStopped event', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.AgentExecutionStopped, value: { reason: 'Stopped by hook' }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test stop', prompt_id: 'prompt-id-stop', }); expect(processStderrSpy).toHaveBeenCalledWith( 'Agent execution stopped: Stopped by hook\n', ); // Should exit without calling sendMessageStream again expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); }); it('should handle AgentExecutionBlocked event', async () => { const allEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.AgentExecutionBlocked, value: { reason: 'Blocked by hook' }, }, { type: GeminiEventType.Content, value: 'Final answer' }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(allEvents), ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'test block', prompt_id: 'prompt-id-block', }); expect(processStderrSpy).toHaveBeenCalledWith( '[WARNING] Agent execution blocked: Blocked by hook\n', ); // sendMessageStream is called once, recursion is internal to it and transparent to the caller expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); expect(getWrittenOutput()).toBe('Final answer\n'); }); }); describe('Output Sanitization', () => { const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m'; const OSC_HYPERLINK = '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\'; const PLAIN_TEXT_RED = 'Red Text'; const PLAIN_TEXT_LINK = 'Link'; it('should sanitize ANSI output by default', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, { type: GeminiEventType.Content, value: ' ' }, { type: GeminiEventType.Content, value: OSC_HYPERLINK }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-sanitization', }); expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`); }); it('should allow ANSI output when rawOutput is true', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, { type: GeminiEventType.Content, value: ' ' }, { type: GeminiEventType.Content, value: OSC_HYPERLINK }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-raw', }); expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`); }); it('should allow ANSI output when only acceptRawOutputRisk is true', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-accept-only', }); expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`); }); it('should warn when rawOutput is true and acceptRisk is false', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-warn', }); expect(processStderrSpy).toHaveBeenCalledWith( expect.stringContaining('[WARNING] --raw-output is enabled'), ); }); it('should not warn when rawOutput is true and acceptRisk is true', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Finished, value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-no-warn', }); expect(processStderrSpy).not.toHaveBeenCalledWith( expect.stringContaining('[WARNING] --raw-output is enabled'), ); }); it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { callId: 'tool-1', name: 'testTool', args: { arg1: 'value1' }, isClientInitiated: false, prompt_id: 'prompt-id-cancel', }, }; // Mock the scheduler to return a cancelled status mockSchedulerSchedule.mockResolvedValue([ { status: CoreToolCallStatus.Cancelled, request: toolCallEvent.value, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, response: { callId: 'tool-1', responseParts: [{ text: 'Operation cancelled' }], resultDisplay: 'Cancelled', }, }, ]); const events: ServerGeminiStreamEvent[] = [ toolCallEvent, { type: GeminiEventType.Content, value: 'Model continues...', }, ]; mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, ); vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( MOCK_SESSION_METRICS, ); await runNonInteractive({ config: mockConfig, settings: mockSettings, input: 'Test input', prompt_id: 'prompt-id-cancel', }); const output = getWrittenOutput(); expect(output).toContain('"type":"tool_result"'); expect(output).toContain('"status":"success"'); }); }); }); ================================================ FILE: packages/cli/src/nonInteractiveCli.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Config, ToolCallRequestInfo, ResumedSessionData, UserFeedbackPayload, } from '@google/gemini-cli-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { convertSessionToClientHistory, GeminiEventType, FatalInputError, promptIdContext, OutputFormat, JsonFormatter, StreamJsonFormatter, JsonStreamEventType, uiTelemetryService, debugLogger, coreEvents, CoreEvent, createWorkingStdio, recordToolCallInteractions, ToolErrorType, Scheduler, ROOT_SCHEDULER_ID, } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; import readline from 'node:readline'; import stripAnsi from 'strip-ansi'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; import { handleError, handleToolError, handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; import { TextOutput } from './ui/utils/textOutput.js'; interface RunNonInteractiveParams { config: Config; settings: LoadedSettings; input: string; prompt_id: string; resumedSessionData?: ResumedSessionData; } export async function runNonInteractive({ config, settings, input, prompt_id, resumedSessionData, }: RunNonInteractiveParams): Promise { return promptIdContext.run(prompt_id, async () => { const consolePatcher = new ConsolePatcher({ stderr: true, debugMode: config.getDebugMode(), onNewMessage: (msg) => { coreEvents.emitConsoleLog(msg.type, msg.content); }, }); if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) { const { setupInitialActivityLogger } = await import( './utils/devtoolsService.js' ); await setupInitialActivityLogger(config); } const { stdout: workingStdout } = createWorkingStdio(); const textOutput = new TextOutput(workingStdout); const handleUserFeedback = (payload: UserFeedbackPayload) => { const prefix = payload.severity.toUpperCase(); process.stderr.write(`[${prefix}] ${payload.message}\n`); if (payload.error && config.getDebugMode()) { const errorToLog = payload.error instanceof Error ? payload.error.stack || payload.error.message : String(payload.error); process.stderr.write(`${errorToLog}\n`); } }; const startTime = Date.now(); const streamFormatter = config.getOutputFormat() === OutputFormat.STREAM_JSON ? new StreamJsonFormatter() : null; const abortController = new AbortController(); // Track cancellation state let isAborting = false; let cancelMessageTimer: NodeJS.Timeout | null = null; // Setup stdin listener for Ctrl+C detection let stdinWasRaw = false; let rl: readline.Interface | null = null; const setupStdinCancellation = () => { // Only setup if stdin is a TTY (user can interact) if (!process.stdin.isTTY) { return; } // Save original raw mode state stdinWasRaw = process.stdin.isRaw || false; // Enable raw mode to capture individual keypresses process.stdin.setRawMode(true); process.stdin.resume(); // Setup readline to emit keypress events rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 0, }); readline.emitKeypressEvents(process.stdin, rl); // Listen for Ctrl+C const keypressHandler = ( str: string, key: { name?: string; ctrl?: boolean }, ) => { // Detect Ctrl+C: either ctrl+c key combo or raw character code 3 if ((key && key.ctrl && key.name === 'c') || str === '\u0003') { // Only handle once if (isAborting) { return; } isAborting = true; // Only show message if cancellation takes longer than 200ms // This reduces verbosity for fast cancellations cancelMessageTimer = setTimeout(() => { process.stderr.write('\nCancelling...\n'); }, 200); abortController.abort(); // Note: Don't exit here - let the abort flow through the system // and trigger handleCancellationError() which will exit with proper code } }; process.stdin.on('keypress', keypressHandler); }; const cleanupStdinCancellation = () => { // Clear any pending cancel message timer if (cancelMessageTimer) { clearTimeout(cancelMessageTimer); cancelMessageTimer = null; } // Cleanup readline and stdin listeners if (rl) { rl.close(); rl = null; } // Remove keypress listener process.stdin.removeAllListeners('keypress'); // Restore stdin to original state if (process.stdin.isTTY) { process.stdin.setRawMode(stdinWasRaw); process.stdin.pause(); } }; let errorToHandle: unknown | undefined; try { consolePatcher.patch(); if ( config.getRawOutput() && !config.getAcceptRawOutputRisk() && config.getOutputFormat() === OutputFormat.TEXT ) { process.stderr.write( '[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n', ); } // Setup stdin cancellation listener setupStdinCancellation(); coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); coreEvents.drainBacklogs(); // Handle EPIPE errors when the output is piped to a command that closes early. process.stdout.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { // Exit gracefully if the pipe is closed. process.exit(0); } }); const geminiClient = config.getGeminiClient(); const scheduler = new Scheduler({ context: config, messageBus: config.getMessageBus(), getPreferredEditor: () => undefined, schedulerId: ROOT_SCHEDULER_ID, }); // Initialize chat. Resume if resume data is passed. if (resumedSessionData) { await geminiClient.resumeChat( convertSessionToClientHistory( resumedSessionData.conversation.messages, ), resumedSessionData, ); } // Emit init event for streaming JSON if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.INIT, timestamp: new Date().toISOString(), session_id: config.getSessionId(), model: config.getModel(), }); } let query: Part[] | undefined; if (isSlashCommand(input)) { const slashCommandResult = await handleSlashCommand( input, abortController, config, settings, ); // If a slash command is found and returns a prompt, use it. // Otherwise, slashCommandResult falls through to the default prompt // handling. if (slashCommandResult) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = slashCommandResult as Part[]; } } if (!query) { const { processedQuery, error } = await handleAtCommand({ query: input, config, addItem: (_item, _timestamp) => 0, onDebugMessage: () => {}, messageId: Date.now(), signal: abortController.signal, escapePastedAtSymbols: false, }); if (error || !processedQuery) { // An error occurred during @include processing (e.g., file not found). // The error message is already logged by handleAtCommand. throw new FatalInputError( error || 'Exiting due to an error processing the @ command.', ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = processedQuery as Part[]; } // Emit user message event for streaming JSON if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.MESSAGE, timestamp: new Date().toISOString(), role: 'user', content: input, }); } let currentMessages: Content[] = [{ role: 'user', parts: query }]; let turnCount = 0; while (true) { turnCount++; if ( config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { handleMaxTurnsExceededError(config); } const toolCallRequests: ToolCallRequestInfo[] = []; const responseStream = geminiClient.sendMessageStream( currentMessages[0]?.parts || [], abortController.signal, prompt_id, undefined, false, turnCount === 1 ? input : undefined, ); let responseText = ''; for await (const event of responseStream) { if (abortController.signal.aborted) { handleCancellationError(config); } if (event.type === GeminiEventType.Content) { const isRaw = config.getRawOutput() || config.getAcceptRawOutputRisk(); const output = isRaw ? event.value : stripAnsi(event.value); if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.MESSAGE, timestamp: new Date().toISOString(), role: 'assistant', content: output, delta: true, }); } else if (config.getOutputFormat() === OutputFormat.JSON) { responseText += output; } else { if (event.value) { textOutput.write(output); } } } else if (event.type === GeminiEventType.ToolCallRequest) { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_USE, timestamp: new Date().toISOString(), tool_name: event.value.name, tool_id: event.value.callId, parameters: event.value.args, }); } toolCallRequests.push(event.value); } else if (event.type === GeminiEventType.LoopDetected) { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.ERROR, timestamp: new Date().toISOString(), severity: 'warning', message: 'Loop detected, stopping execution', }); } } else if (event.type === GeminiEventType.MaxSessionTurns) { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.ERROR, timestamp: new Date().toISOString(), severity: 'error', message: 'Maximum session turns exceeded', }); } } else if (event.type === GeminiEventType.Error) { throw event.value.error; } else if (event.type === GeminiEventType.AgentExecutionStopped) { const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`; if (config.getOutputFormat() === OutputFormat.TEXT) { process.stderr.write(`${stopMessage}\n`); } // Emit final result event for streaming JSON if needed if (streamFormatter) { const metrics = uiTelemetryService.getMetrics(); const durationMs = Date.now() - startTime; streamFormatter.emitEvent({ type: JsonStreamEventType.RESULT, timestamp: new Date().toISOString(), status: 'success', stats: streamFormatter.convertToStreamStats( metrics, durationMs, ), }); } return; } else if (event.type === GeminiEventType.AgentExecutionBlocked) { const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`; if (config.getOutputFormat() === OutputFormat.TEXT) { process.stderr.write(`[WARNING] ${blockMessage}\n`); } } } if (toolCallRequests.length > 0) { textOutput.ensureTrailingNewline(); const completedToolCalls = await scheduler.schedule( toolCallRequests, abortController.signal, ); const toolResponseParts: Part[] = []; for (const completedToolCall of completedToolCalls) { const toolResponse = completedToolCall.response; const requestInfo = completedToolCall.request; if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, timestamp: new Date().toISOString(), tool_id: requestInfo.callId, status: completedToolCall.status === 'error' ? 'error' : 'success', output: typeof toolResponse.resultDisplay === 'string' ? toolResponse.resultDisplay : undefined, error: toolResponse.error ? { type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR', message: toolResponse.error.message, } : undefined, }); } if (toolResponse.error) { handleToolError( requestInfo.name, toolResponse.error, config, toolResponse.errorType || 'TOOL_EXECUTION_ERROR', typeof toolResponse.resultDisplay === 'string' ? toolResponse.resultDisplay : undefined, ); } if (toolResponse.responseParts) { toolResponseParts.push(...toolResponse.responseParts); } } // Record tool calls with full metadata before sending responses to Gemini try { const currentModel = geminiClient.getCurrentSequenceModel() ?? config.getModel(); geminiClient .getChat() .recordCompletedToolCalls(currentModel, completedToolCalls); await recordToolCallInteractions(config, completedToolCalls); } catch (error) { debugLogger.error( `Error recording completed tool call information: ${error}`, ); } // Check if any tool requested to stop execution immediately const stopExecutionTool = completedToolCalls.find( (tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION, ); if (stopExecutionTool && stopExecutionTool.response.error) { const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`; if (config.getOutputFormat() === OutputFormat.TEXT) { process.stderr.write(`${stopMessage}\n`); } // Emit final result event for streaming JSON if (streamFormatter) { const metrics = uiTelemetryService.getMetrics(); const durationMs = Date.now() - startTime; streamFormatter.emitEvent({ type: JsonStreamEventType.RESULT, timestamp: new Date().toISOString(), status: 'success', stats: streamFormatter.convertToStreamStats( metrics, durationMs, ), }); } else if (config.getOutputFormat() === OutputFormat.JSON) { const formatter = new JsonFormatter(); const stats = uiTelemetryService.getMetrics(); textOutput.write( formatter.format(config.getSessionId(), responseText, stats), ); } else { textOutput.ensureTrailingNewline(); // Ensure a final newline } return; } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { // Emit final result event for streaming JSON if (streamFormatter) { const metrics = uiTelemetryService.getMetrics(); const durationMs = Date.now() - startTime; streamFormatter.emitEvent({ type: JsonStreamEventType.RESULT, timestamp: new Date().toISOString(), status: 'success', stats: streamFormatter.convertToStreamStats(metrics, durationMs), }); } else if (config.getOutputFormat() === OutputFormat.JSON) { const formatter = new JsonFormatter(); const stats = uiTelemetryService.getMetrics(); textOutput.write( formatter.format(config.getSessionId(), responseText, stats), ); } else { textOutput.ensureTrailingNewline(); // Ensure a final newline } return; } } } catch (error) { errorToHandle = error; } finally { // Cleanup stdin cancellation before other cleanup cleanupStdinCancellation(); consolePatcher.cleanup(); coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); } if (errorToHandle) { handleError(errorToHandle, config); } }); } ================================================ FILE: packages/cli/src/nonInteractiveCliCommands.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { PartListUnion } from '@google/genai'; import { parseSlashCommand } from './utils/commands.js'; import { FatalInputError, Logger, uiTelemetryService, type Config, } from '@google/gemini-cli-core'; import { CommandService } from './services/CommandService.js'; import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; import { McpPromptLoader } from './services/McpPromptLoader.js'; import type { CommandContext } from './ui/commands/types.js'; import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; /** * Processes a slash command in a non-interactive environment. * * @returns A Promise that resolves to `PartListUnion` if a valid command is * found and results in a prompt, or `undefined` otherwise. * @throws {FatalInputError} if the command result is not supported in * non-interactive mode. */ export const handleSlashCommand = async ( rawQuery: string, abortController: AbortController, config: Config, settings: LoadedSettings, ): Promise => { const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/')) { return; } const commandService = await CommandService.create( [ new BuiltinCommandLoader(config), new McpPromptLoader(config), new FileCommandLoader(config), ], abortController.signal, ); const commands = commandService.getCommands(); const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); if (commandToExecute) { if (commandToExecute.action) { // Not used by custom commands but may be in the future. const sessionStats: SessionStatsState = { sessionId: config?.getSessionId(), sessionStartTime: new Date(), metrics: uiTelemetryService.getMetrics(), lastPromptTokenCount: 0, promptCount: 1, }; const logger = new Logger(config?.getSessionId() || '', config?.storage); const commandContext: CommandContext = { services: { agentContext: config, settings, git: undefined, logger, }, ui: createNonInteractiveUI(), session: { stats: sessionStats, sessionShellAllowlist: new Set(), }, invocation: { raw: trimmed, name: commandToExecute.name, args, }, }; const result = await commandToExecute.action(commandContext, args); if (result) { switch (result.type) { case 'submit_prompt': return result.content; case 'confirm_shell_commands': // This result indicates a command attempted to confirm shell commands. // However note that currently, ShellTool is excluded in non-interactive // mode unless 'YOLO mode' is active, so confirmation actually won't // occur because of YOLO mode. // This ensures that if a command *does* request confirmation (e.g. // in the future with more granular permissions), it's handled appropriately. throw new FatalInputError( 'Exiting due to a confirmation prompt requested by the command.', ); default: throw new FatalInputError( 'Exiting due to command result that is not supported in non-interactive mode.', ); } } } } return; }; ================================================ FILE: packages/cli/src/patches/is-in-ci.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // This is a replacement for the `is-in-ci` package that always returns false. // We are doing this to avoid the issue where `ink` does not render the UI // when it detects that it is running in a CI environment. // This is safe because `ink` (and thus `is-in-ci`) is only used in the // interactive code path of the CLI. // See issue #1563 for more details. const isInCi = false; // eslint-disable-next-line import/no-default-export export default isInCi; ================================================ FILE: packages/cli/src/services/BuiltinCommandLoader.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ vi.mock('../ui/commands/profileCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { profileCommand: { name: 'profile', description: 'Profile command', kind: CommandKind.BUILT_IN, }, }; }); vi.mock('../ui/commands/aboutCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { aboutCommand: { name: 'about', description: 'About the CLI', kind: CommandKind.BUILT_IN, }, }; }); vi.mock('../ui/commands/ideCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { ideCommand: vi.fn().mockResolvedValue({ name: 'ide', description: 'IDE command', kind: CommandKind.BUILT_IN, }), }; }); vi.mock('../ui/commands/restoreCommand.js', () => ({ restoreCommand: vi.fn(), })); vi.mock('../ui/commands/permissionsCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { permissionsCommand: { name: 'permissions', description: 'Permissions command', kind: CommandKind.BUILT_IN, }, }; }); import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; import { isNightly, type Config } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isNightly: vi.fn().mockResolvedValue(false), }; }); vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); vi.mock('../ui/commands/agentsCommand.js', () => ({ agentsCommand: { name: 'agents' }, })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: { name: 'chat', subCommands: [ { name: 'list' }, { name: 'save' }, { name: 'resume' }, { name: 'delete' }, { name: 'share' }, { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, ], }, debugCommand: { name: 'debug' }, })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: () => ({}), })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/shortcutsCommand.js', () => ({ shortcutsCommand: {}, })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); vi.mock('../ui/commands/modelCommand.js', () => ({ modelCommand: { name: 'model' }, })); vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} })); vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: { name: 'resume', subCommands: [ { name: 'list' }, { name: 'save' }, { name: 'resume' }, { name: 'delete' }, { name: 'share' }, { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, ], }, })); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); vi.mock('../ui/commands/skillsCommand.js', () => ({ skillsCommand: { name: 'skills' }, })); vi.mock('../ui/commands/planCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { planCommand: { name: 'plan', description: 'Plan command', kind: CommandKind.BUILT_IN, }, }; }); vi.mock('../ui/commands/mcpCommand.js', () => ({ mcpCommand: { name: 'mcp', description: 'MCP command', kind: 'BUILT_IN', }, })); vi.mock('../ui/commands/upgradeCommand.js', () => ({ upgradeCommand: { name: 'upgrade', description: 'Upgrade command', kind: 'BUILT_IN', }, })); describe('BuiltinCommandLoader', () => { let mockConfig: Config; const restoreCommandMock = restoreCommand as Mock; beforeEach(() => { vi.clearAllMocks(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(true), isPlanEnabled: vi.fn().mockReturnValue(true), getEnableExtensionReloading: () => false, getEnableHooks: () => false, getEnableHooksUI: () => false, getExtensionsEnabled: vi.fn().mockReturnValue(true), isSkillsSupportEnabled: vi.fn().mockReturnValue(true), isAgentsEnabled: vi.fn().mockReturnValue(false), getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'other', }), } as unknown as Config; restoreCommandMock.mockReturnValue({ name: 'restore', description: 'Restore command', kind: CommandKind.BUILT_IN, }); }); it('should include upgrade command when authType is login_with_google', async () => { const { AuthType } = await import('@google/gemini-cli-core'); (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ authType: AuthType.LOGIN_WITH_GOOGLE, }); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const upgradeCmd = commands.find((c) => c.name === 'upgrade'); expect(upgradeCmd).toBeDefined(); }); it('should exclude upgrade command when authType is NOT login_with_google', async () => { (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ authType: 'other', }); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const upgradeCmd = commands.find((c) => c.name === 'upgrade'); expect(upgradeCmd).toBeUndefined(); }); it('should correctly pass the config object to restore command factory', async () => { const loader = new BuiltinCommandLoader(mockConfig); await loader.loadCommands(new AbortController().signal); // ideCommand is now a constant, no longer needs config expect(restoreCommandMock).toHaveBeenCalledTimes(1); expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig); }); it('should filter out null command definitions returned by factories', async () => { // ideCommand is now a constant SlashCommand const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); // The 'ide' command should be present. const ideCmd = commands.find((c) => c.name === 'ide'); expect(ideCmd).toBeDefined(); // Other commands should still be present. const aboutCmd = commands.find((c) => c.name === 'about'); expect(aboutCmd).toBeDefined(); }); it('should handle a null config gracefully when calling factories', async () => { const loader = new BuiltinCommandLoader(null); await loader.loadCommands(new AbortController().signal); // ideCommand is now a constant, no longer needs config expect(restoreCommandMock).toHaveBeenCalledTimes(1); expect(restoreCommandMock).toHaveBeenCalledWith(null); }); it('should return a list of all loaded commands', async () => { const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const aboutCmd = commands.find((c) => c.name === 'about'); expect(aboutCmd).toBeDefined(); expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN); const ideCmd = commands.find((c) => c.name === 'ide'); expect(ideCmd).toBeDefined(); const mcpCmd = commands.find((c) => c.name === 'mcp'); expect(mcpCmd).toBeDefined(); }); it('should include permissions command when folder trust is enabled', async () => { const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const permissionsCmd = commands.find((c) => c.name === 'permissions'); expect(permissionsCmd).toBeDefined(); }); it('should exclude permissions command when folder trust is disabled', async () => { (mockConfig.getFolderTrust as Mock).mockReturnValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const permissionsCmd = commands.find((c) => c.name === 'permissions'); expect(permissionsCmd).toBeUndefined(); }); it('should include policies command when message bus integration is enabled', async () => { const mockConfigWithMessageBus = { ...mockConfig, getEnableHooks: () => false, getMcpEnabled: () => true, } as unknown as Config; const loader = new BuiltinCommandLoader(mockConfigWithMessageBus); const commands = await loader.loadCommands(new AbortController().signal); const policiesCmd = commands.find((c) => c.name === 'policies'); expect(policiesCmd).toBeDefined(); }); it('should include agents command when agents are enabled', async () => { mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(true); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const agentsCmd = commands.find((c) => c.name === 'agents'); expect(agentsCmd).toBeDefined(); }); it('should include plan command when plan mode is enabled', async () => { (mockConfig.isPlanEnabled as Mock).mockReturnValue(true); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const planCmd = commands.find((c) => c.name === 'plan'); expect(planCmd).toBeDefined(); }); it('should exclude plan command when plan mode is disabled', async () => { (mockConfig.isPlanEnabled as Mock).mockReturnValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const planCmd = commands.find((c) => c.name === 'plan'); expect(planCmd).toBeUndefined(); }); it('should exclude agents command when agents are disabled', async () => { mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const agentsCmd = commands.find((c) => c.name === 'agents'); expect(agentsCmd).toBeUndefined(); }); describe('chat debug command', () => { it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const chatCmd = commands.find((c) => c.name === 'chat'); expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(false); const resumeCmd = commands.find((c) => c.name === 'resume'); const resumeHasDebug = resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(resumeHasDebug).toBe(false); const chatCheckpointsCmd = chatCmd?.subCommands?.find( (c) => c.name === 'checkpoints', ); const chatCheckpointHasDebug = chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(chatCheckpointHasDebug).toBe(false); const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( (c) => c.name === 'checkpoints', ); const resumeCheckpointHasDebug = resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(resumeCheckpointHasDebug).toBe(false); }); it('should add debug subcommand to chat/resume commands if it is a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(true); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const chatCmd = commands.find((c) => c.name === 'chat'); expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(true); const resumeCmd = commands.find((c) => c.name === 'resume'); const resumeHasDebug = resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(resumeHasDebug).toBe(true); const chatCheckpointsCmd = chatCmd?.subCommands?.find( (c) => c.name === 'checkpoints', ); const chatCheckpointHasDebug = chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(chatCheckpointHasDebug).toBe(true); const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( (c) => c.name === 'checkpoints', ); const resumeCheckpointHasDebug = resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; expect(resumeCheckpointHasDebug).toBe(true); }); }); }); describe('BuiltinCommandLoader profile', () => { let mockConfig: Config; beforeEach(() => { vi.resetModules(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(false), isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: () => false, getEnableExtensionReloading: () => false, getEnableHooks: () => false, getEnableHooksUI: () => false, getExtensionsEnabled: vi.fn().mockReturnValue(true), isSkillsSupportEnabled: vi.fn().mockReturnValue(true), isAgentsEnabled: vi.fn().mockReturnValue(false), getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'other', }), } as unknown as Config; }); it('should not include profile command when isDevelopment is false', async () => { process.env['NODE_ENV'] = 'production'; const { BuiltinCommandLoader } = await import('./BuiltinCommandLoader.js'); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const profileCmd = commands.find((c) => c.name === 'profile'); expect(profileCmd).toBeUndefined(); }); it('should include profile command when isDevelopment is true', async () => { process.env['NODE_ENV'] = 'development'; const { BuiltinCommandLoader } = await import('./BuiltinCommandLoader.js'); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); const profileCmd = commands.find((c) => c.name === 'profile'); expect(profileCmd).toBeDefined(); }); }); ================================================ FILE: packages/cli/src/services/BuiltinCommandLoader.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { isDevelopment } from '../utils/installationInfo.js'; import type { ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand, type CommandContext, } from '../ui/commands/types.js'; import type { MessageActionReturn, Config } from '@google/gemini-cli-core'; import { isNightly, startupProfiler, getAdminErrorMessage, AuthType, } from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { commandsCommand } from '../ui/commands/commandsCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; import { oncallCommand } from '../ui/commands/oncallCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { planCommand } from '../ui/commands/planCommand.js'; import { policiesCommand } from '../ui/commands/policiesCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { profileCommand } from '../ui/commands/profileCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; import { upgradeCommand } from '../ui/commands/upgradeCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part * of the Gemini CLI application. */ export class BuiltinCommandLoader implements ICommandLoader { constructor(private config: Config | null) {} /** * Gathers all raw built-in command definitions, injects dependencies where * needed (e.g., config) and filters out any that are not available. * * @param _signal An AbortSignal (unused for this synchronous loader). * @returns A promise that resolves to an array of `SlashCommand` objects. */ async loadCommands(_signal: AbortSignal): Promise { const handle = startupProfiler.start('load_builtin_commands'); const isNightlyBuild = await isNightly(process.cwd()); const addDebugToChatResumeSubCommands = ( subCommands: SlashCommand[] | undefined, ): SlashCommand[] | undefined => { if (!subCommands) { return subCommands; } const withNestedCompatibility = subCommands.map((subCommand) => { if (subCommand.name !== 'checkpoints') { return subCommand; } return { ...subCommand, subCommands: addDebugToChatResumeSubCommands(subCommand.subCommands), }; }); if (!isNightlyBuild) { return withNestedCompatibility; } return withNestedCompatibility.some( (cmd) => cmd.name === debugCommand.name, ) ? withNestedCompatibility : [ ...withNestedCompatibility, { ...debugCommand, suggestionGroup: 'checkpoints' }, ]; }; const chatResumeSubCommands = addDebugToChatResumeSubCommands( chatCommand.subCommands, ); const allDefinitions: Array = [ aboutCommand, ...(this.config?.isAgentsEnabled() ? [agentsCommand] : []), authCommand, bugCommand, { ...chatCommand, subCommands: chatResumeSubCommands, }, clearCommand, commandsCommand, compressCommand, copyCommand, corgiCommand, docsCommand, directoryCommand, editorCommand, ...(this.config?.getExtensionsEnabled() === false ? [ { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [], action: async ( _context: CommandContext, ): Promise => ({ type: 'message', messageType: 'error', content: getAdminErrorMessage( 'Extensions', this.config ?? undefined, ), }), }, ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, footerCommand, shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, await ideCommand(), initCommand, ...(isNightlyBuild ? [oncallCommand] : []), ...(this.config?.getMcpEnabled() === false ? [ { name: 'mcp', description: 'Manage configured Model Context Protocol (MCP) servers', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [], action: async ( _context: CommandContext, ): Promise => ({ type: 'message', messageType: 'error', content: getAdminErrorMessage('MCP', this.config ?? undefined), }), }, ] : [mcpCommand]), memoryCommand, modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), ...(this.config?.isPlanEnabled() ? [planCommand] : []), policiesCommand, privacyCommand, ...(isDevelopment ? [profileCommand] : []), quitCommand, restoreCommand(this.config), { ...resumeCommand, subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands), }, statsCommand, themeCommand, toolsCommand, ...(this.config?.isSkillsSupportEnabled() ? this.config?.getSkillManager()?.isAdminEnabled() === false ? [ { name: 'skills', description: 'Manage agent skills', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [], action: async ( _context: CommandContext, ): Promise => ({ type: 'message', messageType: 'error', content: getAdminErrorMessage( 'Agent skills', this.config ?? undefined, ), }), }, ] : [skillsCommand] : []), settingsCommand, shellsCommand, vimCommand, setupGithubCommand, terminalSetupCommand, ...(this.config?.getContentGeneratorConfig()?.authType === AuthType.LOGIN_WITH_GOOGLE ? [upgradeCommand] : []), ]; handle?.end(); return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); } } ================================================ FILE: packages/cli/src/services/CommandService.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { CommandService } from './CommandService.js'; import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ name, description: `Description for ${name}`, kind, action: vi.fn(), }); class MockCommandLoader implements ICommandLoader { constructor(private readonly commands: SlashCommand[]) {} loadCommands = vi.fn(async () => Promise.resolve(this.commands)); } describe('CommandService', () => { beforeEach(() => { vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('basic loading', () => { it('should aggregate commands from multiple successful loaders', async () => { const cmdA = createMockCommand('a', CommandKind.BUILT_IN); const cmdB = createMockCommand('b', CommandKind.USER_FILE); const service = await CommandService.create( [new MockCommandLoader([cmdA]), new MockCommandLoader([cmdB])], new AbortController().signal, ); expect(service.getCommands()).toHaveLength(2); expect(service.getCommands()).toEqual( expect.arrayContaining([cmdA, cmdB]), ); }); it('should handle empty loaders and failed loaders gracefully', async () => { const cmdA = createMockCommand('a', CommandKind.BUILT_IN); const failingLoader = new MockCommandLoader([]); vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue( new Error('fail'), ); const service = await CommandService.create( [ new MockCommandLoader([cmdA]), new MockCommandLoader([]), failingLoader, ], new AbortController().signal, ); expect(service.getCommands()).toHaveLength(1); expect(service.getCommands()[0].name).toBe('a'); expect(debugLogger.debug).toHaveBeenCalledWith( 'A command loader failed:', expect.any(Error), ); }); it('should return a readonly array of commands', async () => { const service = await CommandService.create( [new MockCommandLoader([createMockCommand('a', CommandKind.BUILT_IN)])], new AbortController().signal, ); expect(() => (service.getCommands() as unknown[]).push({})).toThrow(); }); it('should pass the abort signal to all loaders', async () => { const controller = new AbortController(); const loader = new MockCommandLoader([]); await CommandService.create([loader], controller.signal); expect(loader.loadCommands).toHaveBeenCalledWith(controller.signal); }); }); describe('conflict delegation', () => { it('should delegate conflict resolution to SlashCommandResolver', async () => { const builtin = createMockCommand('help', CommandKind.BUILT_IN); const user = createMockCommand('help', CommandKind.USER_FILE); const service = await CommandService.create( [new MockCommandLoader([builtin, user])], new AbortController().signal, ); expect(service.getCommands().map((c) => c.name)).toContain('help'); expect(service.getCommands().map((c) => c.name)).toContain('user.help'); expect(service.getConflicts()).toHaveLength(1); }); }); }); ================================================ FILE: packages/cli/src/services/CommandService.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger, coreEvents } from '@google/gemini-cli-core'; import type { SlashCommand } from '../ui/commands/types.js'; import type { ICommandLoader, CommandConflict } from './types.js'; import { SlashCommandResolver } from './SlashCommandResolver.js'; /** * Orchestrates the discovery and loading of all slash commands for the CLI. * * This service operates on a provider-based loader pattern. It is initialized * with an array of `ICommandLoader` instances, each responsible for fetching * commands from a specific source (e.g., built-in code, local files). * * It uses a delegating resolver to reconcile name conflicts, ensuring that * all commands are uniquely addressable via source-specific prefixes while * allowing built-in commands to retain their primary names. */ export class CommandService { /** * Private constructor to enforce the use of the async factory. * @param commands A readonly array of the fully loaded and de-duplicated commands. * @param conflicts A readonly array of conflicts that occurred during loading. */ private constructor( private readonly commands: readonly SlashCommand[], private readonly conflicts: readonly CommandConflict[], ) {} /** * Asynchronously creates and initializes a new CommandService instance. * * This factory method orchestrates the loading process and delegates * conflict resolution to the SlashCommandResolver. * * @param loaders An array of loaders to fetch commands from. * @param signal An AbortSignal to allow cancellation. * @returns A promise that resolves to a fully initialized CommandService. */ static async create( loaders: ICommandLoader[], signal: AbortSignal, ): Promise { const allCommands = await this.loadAllCommands(loaders, signal); const { finalCommands, conflicts } = SlashCommandResolver.resolve(allCommands); if (conflicts.length > 0) { this.emitConflictEvents(conflicts); } return new CommandService( Object.freeze(finalCommands), Object.freeze(conflicts), ); } /** * Invokes all loaders in parallel and flattens the results. */ private static async loadAllCommands( loaders: ICommandLoader[], signal: AbortSignal, ): Promise { const results = await Promise.allSettled( loaders.map((loader) => loader.loadCommands(signal)), ); const commands: SlashCommand[] = []; for (const result of results) { if (result.status === 'fulfilled') { commands.push(...result.value); } else { debugLogger.debug('A command loader failed:', result.reason); } } return commands; } /** * Formats and emits telemetry for command conflicts. */ private static emitConflictEvents(conflicts: CommandConflict[]): void { coreEvents.emitSlashCommandConflicts( conflicts.flatMap((c) => c.losers.map((l) => ({ name: c.name, renamedTo: l.renamedTo, loserExtensionName: l.command.extensionName, winnerExtensionName: l.reason.extensionName, loserMcpServerName: l.command.mcpServerName, winnerMcpServerName: l.reason.mcpServerName, loserKind: l.command.kind, winnerKind: l.reason.kind, })), ), ); } /** * Retrieves the currently loaded and de-duplicated list of slash commands. * * This method is a safe accessor for the service's state. It returns a * readonly array, preventing consumers from modifying the service's internal state. * * @returns A readonly, unified array of available `SlashCommand` objects. */ getCommands(): readonly SlashCommand[] { return this.commands; } /** * Retrieves the list of conflicts that occurred during command loading. * * @returns A readonly array of command conflicts. */ getConflicts(): readonly CommandConflict[] { return this.conflicts; } } ================================================ FILE: packages/cli/src/services/FileCommandLoader.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as glob from 'glob'; import * as path from 'node:path'; import { GEMINI_DIR, Storage, type Config } from '@google/gemini-cli-core'; import mock from 'mock-fs'; import { FileCommandLoader } from './FileCommandLoader.js'; import { assert, vi } from 'vitest'; import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; import { SHELL_INJECTION_TRIGGER, SHORTHAND_ARGS_PLACEHOLDER, type PromptPipelineContent, } from './prompt-processors/types.js'; import { ConfirmationRequiredError, ShellProcessor, } from './prompt-processors/shellProcessor.js'; import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; import type { CommandContext } from '../ui/commands/types.js'; import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; const mockShellProcess = vi.hoisted(() => vi.fn()); const mockAtFileProcess = vi.hoisted(() => vi.fn()); vi.mock('./prompt-processors/atFileProcessor.js', () => ({ AtFileProcessor: vi.fn().mockImplementation(() => ({ process: mockAtFileProcess, })), })); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, })), ConfirmationRequiredError: class extends Error { constructor( message: string, public commandsToConfirm: string[], ) { super(message); this.name = 'ConfirmationRequiredError'; } }, })); vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => { const original = await importOriginal< typeof import('./prompt-processors/argumentProcessor.js') >(); return { DefaultArgumentProcessor: vi .fn() .mockImplementation(() => new original.DefaultArgumentProcessor()), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, Storage: original.Storage, isCommandAllowed: vi.fn(), ShellExecutionService: { execute: vi.fn(), }, }; }); vi.mock('glob', () => ({ glob: vi.fn(), })); describe('FileCommandLoader', () => { const signal: AbortSignal = new AbortController().signal; beforeEach(async () => { vi.clearAllMocks(); const { glob: actualGlob } = await vi.importActual('glob'); vi.mocked(glob.glob).mockImplementation(actualGlob); mockShellProcess.mockImplementation( (prompt: PromptPipelineContent, context: CommandContext) => { const userArgsRaw = context?.invocation?.args || ''; // This is a simplified mock. A real implementation would need to iterate // through all parts and process only the text parts. const firstTextPart = prompt.find( (p) => typeof p === 'string' || 'text' in p, ); let textContent = ''; if (typeof firstTextPart === 'string') { textContent = firstTextPart; } else if (firstTextPart && 'text' in firstTextPart) { textContent = firstTextPart.text ?? ''; } const processedText = textContent.replaceAll( SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw, ); return Promise.resolve([{ text: processedText }]); }, ); mockAtFileProcess.mockImplementation(async (prompt: string) => prompt); }); afterEach(() => { mock.restore(); }); it('loads a single command from a file', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "This is a test prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command).toBeDefined(); expect(command.name).toBe('test'); const result = await command.action?.( createMockCommandContext({ invocation: { raw: '/test', name: 'test', args: '', }, }), '', ); if (result?.type === 'submit_prompt') { expect(result.content).toEqual([{ text: 'This is a test prompt' }]); } else { assert.fail('Incorrect action type'); } }); // Symlink creation on Windows requires special permissions that are not // available in the standard CI environment. Therefore, we skip these tests // on Windows to prevent CI failures. The core functionality is still // validated on Linux and macOS. const itif = (condition: boolean) => (condition ? it : it.skip); itif(process.platform !== 'win32')( 'loads commands from a symlinked directory', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const realCommandsDir = '/real/commands'; mock({ [realCommandsDir]: { 'test.toml': 'prompt = "This is a test prompt"', }, // Symlink the user commands directory to the real one [userCommandsDir]: mock.symlink({ path: realCommandsDir, }), }); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command).toBeDefined(); expect(command.name).toBe('test'); }, ); itif(process.platform !== 'win32')( 'loads commands from a symlinked subdirectory', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const realNamespacedDir = '/real/namespaced-commands'; mock({ [userCommandsDir]: { namespaced: mock.symlink({ path: realNamespacedDir, }), }, [realNamespacedDir]: { 'my-test.toml': 'prompt = "This is a test prompt"', }, }); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command).toBeDefined(); expect(command.name).toBe('namespaced:my-test'); }, ); it('loads multiple commands', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test1.toml': 'prompt = "Prompt 1"', 'test2.toml': 'prompt = "Prompt 2"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(2); }); it('creates deeply nested namespaces correctly', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { gcp: { pipelines: { 'run.toml': 'prompt = "run pipeline"', }, }, }, }); const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('gcp:pipelines:run'); }); it('creates namespaces from nested directories', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { git: { 'commit.toml': 'prompt = "git commit prompt"', }, }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command).toBeDefined(); expect(command.name).toBe('git:commit'); }); it('returns both user and project commands in order', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const projectCommandsDir = new Storage( process.cwd(), ).getProjectCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "User prompt"', }, [projectCommandsDir]: { 'test.toml': 'prompt = "Project prompt"', }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(2); const userResult = await commands[0].action?.( createMockCommandContext({ invocation: { raw: '/test', name: 'test', args: '', }, }), '', ); if (userResult?.type === 'submit_prompt') { expect(userResult.content).toEqual([{ text: 'User prompt' }]); } else { assert.fail('Incorrect action type for user command'); } const projectResult = await commands[1].action?.( createMockCommandContext({ invocation: { raw: '/test', name: 'test', args: '', }, }), '', ); if (projectResult?.type === 'submit_prompt') { expect(projectResult.content).toEqual([{ text: 'Project prompt' }]); } else { assert.fail('Incorrect action type for project command'); } }); it('ignores files with TOML syntax errors', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'invalid.toml': 'this is not valid toml', 'good.toml': 'prompt = "This one is fine"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('good'); }); it('ignores files that are semantically invalid (missing prompt)', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'no_prompt.toml': 'description = "This file is missing a prompt"', 'good.toml': 'prompt = "This one is fine"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('good'); }); it('handles filename edge cases correctly', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.v1.toml': 'prompt = "Test prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); expect(command.name).toBe('test.v1'); }); it('handles file system errors gracefully', async () => { mock({}); // Mock an empty file system const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(0); }); it('uses a default description if not provided', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "Test prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); expect(command.description).toBe('Custom command from test.toml'); }); it('uses the provided description', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); expect(command.description).toBe('My test command'); }); it('should sanitize colons in filenames to prevent namespace conflicts', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'legacy:command.toml': 'prompt = "This is a legacy command"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command).toBeDefined(); // Verify that the ':' in the filename was replaced with an '_' expect(command.name).toBe('legacy_command'); }); describe('Processor Instantiation Logic', () => { it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'simple.toml': `prompt = "Just a regular prompt"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).not.toHaveBeenCalled(); expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); }); it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'args.toml': `prompt = "Prompt with {{args}}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledTimes(1); expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); }); it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Prompt with !{cmd}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledTimes(1); expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); }); it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledTimes(1); expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); }); it('instantiates AtFileProcessor and DefaultArgumentProcessor if @{} is present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'at-file.toml': `prompt = "Context: @{./my-file.txt}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(AtFileProcessor).toHaveBeenCalledTimes(1); expect(ShellProcessor).not.toHaveBeenCalled(); expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); }); it('instantiates ShellProcessor and AtFileProcessor if !{} and @{} are present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell-and-at.toml': `prompt = "Run !{cmd} with @{file.txt}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledTimes(1); expect(AtFileProcessor).toHaveBeenCalledTimes(1); expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); // because no {{args}} }); it('instantiates only ShellProcessor and AtFileProcessor if {{args}} and @{} are present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'args-and-at.toml': `prompt = "Run {{args}} with @{file.txt}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledTimes(1); expect(AtFileProcessor).toHaveBeenCalledTimes(1); expect(DefaultArgumentProcessor).not.toHaveBeenCalled(); }); }); describe('Extension Command Loading', () => { it('loads commands from active extensions', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const projectCommandsDir = new Storage( process.cwd(), ).getProjectCommandsDir(); const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'test-ext', ); mock({ [userCommandsDir]: { 'user.toml': 'prompt = "User command"', }, [projectCommandsDir]: { 'project.toml': 'prompt = "Project command"', }, [extensionDir]: { 'gemini-extension.json': JSON.stringify({ name: 'test-ext', version: '1.0.0', }), commands: { 'ext.toml': 'prompt = "Extension command"', }, }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'test-ext', version: '1.0.0', isActive: true, path: extensionDir, }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(3); const commandNames = commands.map((cmd) => cmd.name); expect(commandNames).toEqual(['user', 'project', 'ext']); const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); expect(extCommand?.description).toMatch(/^\[test-ext\]/); }); it('extension commands have extensionName metadata for conflict resolution', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const projectCommandsDir = new Storage( process.cwd(), ).getProjectCommandsDir(); const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'test-ext', ); mock({ [extensionDir]: { 'gemini-extension.json': JSON.stringify({ name: 'test-ext', version: '1.0.0', }), commands: { 'deploy.toml': 'prompt = "Extension deploy command"', }, }, [userCommandsDir]: { 'deploy.toml': 'prompt = "User deploy command"', }, [projectCommandsDir]: { 'deploy.toml': 'prompt = "Project deploy command"', }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'test-ext', version: '1.0.0', isActive: true, path: extensionDir, }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); // Return all commands, even duplicates expect(commands).toHaveLength(3); expect(commands[0].name).toBe('deploy'); expect(commands[0].extensionName).toBeUndefined(); const result0 = await commands[0].action?.( createMockCommandContext({ invocation: { raw: '/deploy', name: 'deploy', args: '', }, }), '', ); expect(result0?.type).toBe('submit_prompt'); if (result0?.type === 'submit_prompt') { expect(result0.content).toEqual([{ text: 'User deploy command' }]); } expect(commands[1].name).toBe('deploy'); expect(commands[1].extensionName).toBeUndefined(); const result1 = await commands[1].action?.( createMockCommandContext({ invocation: { raw: '/deploy', name: 'deploy', args: '', }, }), '', ); expect(result1?.type).toBe('submit_prompt'); if (result1?.type === 'submit_prompt') { expect(result1.content).toEqual([{ text: 'Project deploy command' }]); } expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); expect(commands[2].description).toMatch(/^\[test-ext\]/); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { raw: '/deploy', name: 'deploy', args: '', }, }), '', ); expect(result2?.type).toBe('submit_prompt'); if (result2?.type === 'submit_prompt') { expect(result2.content).toEqual([{ text: 'Extension deploy command' }]); } }); it('only loads commands from active extensions', async () => { const extensionDir1 = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'active-ext', ); const extensionDir2 = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'inactive-ext', ); mock({ [extensionDir1]: { 'gemini-extension.json': JSON.stringify({ name: 'active-ext', version: '1.0.0', }), commands: { 'active.toml': 'prompt = "Active extension command"', }, }, [extensionDir2]: { 'gemini-extension.json': JSON.stringify({ name: 'inactive-ext', version: '1.0.0', }), commands: { 'inactive.toml': 'prompt = "Inactive extension command"', }, }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'active-ext', version: '1.0.0', isActive: true, path: extensionDir1, }, { name: 'inactive-ext', version: '1.0.0', isActive: false, path: extensionDir2, }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); expect(commands[0].description).toMatch(/^\[active-ext\]/); }); it('handles missing extension commands directory gracefully', async () => { const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'no-commands', ); mock({ [extensionDir]: { 'gemini-extension.json': JSON.stringify({ name: 'no-commands', version: '1.0.0', }), // No commands directory }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'no-commands', version: '1.0.0', isActive: true, path: extensionDir, }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(0); }); it('handles nested command structure in extensions', async () => { const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'a', ); mock({ [extensionDir]: { 'gemini-extension.json': JSON.stringify({ name: 'a', version: '1.0.0', }), commands: { b: { 'c.toml': 'prompt = "Nested command from extension a"', d: { 'e.toml': 'prompt = "Deeply nested command"', }, }, 'simple.toml': 'prompt = "Simple command"', }, }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'a', version: '1.0.0', isActive: true, path: extensionDir }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(3); const commandNames = commands.map((cmd) => cmd.name).sort(); expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']); const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); expect(nestedCmd?.description).toMatch(/^\[a\]/); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ invocation: { raw: '/b:c', name: 'b:c', args: '', }, }), '', ); if (result?.type === 'submit_prompt') { expect(result.content).toEqual([ { text: 'Nested command from extension a' }, ]); } else { assert.fail('Incorrect action type'); } }); it('correctly loads extensionId for extension commands', async () => { const extensionId = 'my-test-ext-id-123'; const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', 'my-test-ext', ); mock({ [extensionDir]: { 'gemini-extension.json': JSON.stringify({ name: 'my-test-ext', id: extensionId, version: '1.0.0', }), commands: { 'my-cmd.toml': 'prompt = "My test command"', }, }, }); const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { name: 'my-test-ext', id: extensionId, version: '1.0.0', isActive: true, path: extensionDir, }, ]), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); const command = commands[0]; expect(command.name).toBe('my-cmd'); expect(command.extensionName).toBe('my-test-ext'); expect(command.extensionId).toBe(extensionId); }); }); describe('Argument Handling Integration (via ShellProcessor)', () => { it('correctly processes a command with {{args}}', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shorthand.toml': 'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"', }, }); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'shorthand'); expect(command).toBeDefined(); const result = await command!.action?.( createMockCommandContext({ invocation: { raw: '/shorthand do something cool', name: 'shorthand', args: 'do something cool', }, }), 'do something cool', ); expect(result?.type).toBe('submit_prompt'); if (result?.type === 'submit_prompt') { expect(result.content).toEqual([ { text: 'The user wants to: do something cool' }, ]); } }); }); describe('Default Argument Processor Integration', () => { it('correctly processes a command without {{args}}', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'model_led.toml': 'prompt = "This is the instruction."\ndescription = "Default processor test"', }, }); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'model_led'); expect(command).toBeDefined(); const result = await command!.action?.( createMockCommandContext({ invocation: { raw: '/model_led 1.2.0 added "a feature"', name: 'model_led', args: '1.2.0 added "a feature"', }, }), '1.2.0 added "a feature"', ); expect(result?.type).toBe('submit_prompt'); if (result?.type === 'submit_prompt') { const expectedContent = 'This is the instruction.\n\n/model_led 1.2.0 added "a feature"'; expect(result.content).toEqual([{ text: expectedContent }]); } }); }); describe('Shell Processor Integration', () => { it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'args_only.toml': `prompt = "Hello {{args}}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledWith('args_only'); }); it('instantiates ShellProcessor if the trigger is present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).toHaveBeenCalledWith('shell'); }); it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'regular.toml': `prompt = "Just a regular prompt"`, }, }); const loader = new FileCommandLoader(null as unknown as Config); await loader.loadCommands(signal); expect(ShellProcessor).not.toHaveBeenCalled(); }); it('returns a "submit_prompt" action if shell processing succeeds', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run !{echo 'hello'}"`, }, }); mockShellProcess.mockResolvedValue([{ text: 'Run hello' }]); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'shell'); expect(command).toBeDefined(); const result = await command!.action!( createMockCommandContext({ invocation: { raw: '/shell', name: 'shell', args: '' }, }), '', ); expect(result?.type).toBe('submit_prompt'); if (result?.type === 'submit_prompt') { expect(result.content).toEqual([{ text: 'Run hello' }]); } }); it('returns a "confirm_shell_commands" action if shell processing requires it', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const rawInvocation = '/shell rm -rf /'; mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run !{rm -rf /}"`, }, }); // Mock the processor to throw the specific error const error = new ConfirmationRequiredError('Confirmation needed', [ 'rm -rf /', ]); mockShellProcess.mockRejectedValue(error); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'shell'); expect(command).toBeDefined(); const result = await command!.action!( createMockCommandContext({ invocation: { raw: rawInvocation, name: 'shell', args: 'rm -rf /' }, }), 'rm -rf /', ); expect(result?.type).toBe('confirm_shell_commands'); if (result?.type === 'confirm_shell_commands') { expect(result.commandsToConfirm).toEqual(['rm -rf /']); expect(result.originalInvocation.raw).toBe(rawInvocation); } }); it('re-throws other errors from the processor', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run !{something}"`, }, }); const genericError = new Error('Something else went wrong'); mockShellProcess.mockRejectedValue(genericError); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'shell'); expect(command).toBeDefined(); await expect( command!.action!( createMockCommandContext({ invocation: { raw: '/shell', name: 'shell', args: '' }, }), '', ), ).rejects.toThrow('Something else went wrong'); }); it('assembles the processor pipeline in the correct order (AtFile -> Shell -> Default)', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { // This prompt uses !{}, @{}, but NOT {{args}}, so all processors should be active. 'pipeline.toml': ` prompt = "Shell says: !{echo foo}. File says: @{./bar.txt}" `, }, './bar.txt': 'bar content', }); const defaultProcessMock = vi .fn() .mockImplementation((p: PromptPipelineContent) => Promise.resolve([ { text: `${(p[0] as { text: string }).text}-default-processed` }, ]), ); mockShellProcess.mockImplementation((p: PromptPipelineContent) => Promise.resolve([ { text: `${(p[0] as { text: string }).text}-shell-processed` }, ]), ); mockAtFileProcess.mockImplementation((p: PromptPipelineContent) => Promise.resolve([ { text: `${(p[0] as { text: string }).text}-at-file-processed` }, ]), ); vi.mocked(DefaultArgumentProcessor).mockImplementation( () => ({ process: defaultProcessMock, }) as unknown as DefaultArgumentProcessor, ); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'pipeline'); expect(command).toBeDefined(); const result = await command!.action!( createMockCommandContext({ invocation: { raw: '/pipeline baz', name: 'pipeline', args: 'baz', }, }), 'baz', ); expect(mockAtFileProcess.mock.invocationCallOrder[0]).toBeLessThan( mockShellProcess.mock.invocationCallOrder[0], ); expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan( defaultProcessMock.mock.invocationCallOrder[0], ); // Verify the flow of the prompt through the processors // 1. AtFile processor runs first expect(mockAtFileProcess).toHaveBeenCalledWith( [{ text: expect.stringContaining('@{./bar.txt}') }], expect.any(Object), ); // 2. Shell processor runs second expect(mockShellProcess).toHaveBeenCalledWith( [{ text: expect.stringContaining('-at-file-processed') }], expect.any(Object), ); // 3. Default processor runs third expect(defaultProcessMock).toHaveBeenCalledWith( [{ text: expect.stringContaining('-shell-processed') }], expect.any(Object), ); if (result?.type === 'submit_prompt') { const contentAsArray = Array.isArray(result.content) ? result.content : [result.content]; expect(contentAsArray.length).toBeGreaterThan(0); const firstPart = contentAsArray[0]; if (typeof firstPart === 'object' && firstPart && 'text' in firstPart) { expect(firstPart.text).toContain( '-at-file-processed-shell-processed-default-processed', ); } else { assert.fail( 'First part of content is not a text part or is a string', ); } } else { assert.fail('Incorrect action type'); } }); }); describe('@-file Processor Integration', () => { it('correctly processes a command with @{file}', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'at-file.toml': 'prompt = "Context from file: @{./test.txt}"\ndescription = "@-file test"', }, './test.txt': 'file content', }); mockAtFileProcess.mockImplementation( async (prompt: PromptPipelineContent) => { // A simplified mock of AtFileProcessor's behavior const textContent = (prompt[0] as { text: string }).text; if (textContent.includes('@{./test.txt}')) { return [ { text: textContent.replace('@{./test.txt}', 'file content'), }, ]; } return prompt; }, ); // Prevent default processor from interfering vi.mocked(DefaultArgumentProcessor).mockImplementation( () => ({ process: (p: PromptPipelineContent) => Promise.resolve(p), }) as unknown as DefaultArgumentProcessor, ); const loader = new FileCommandLoader(null as unknown as Config); const commands = await loader.loadCommands(signal); const command = commands.find((c) => c.name === 'at-file'); expect(command).toBeDefined(); const result = await command!.action?.( createMockCommandContext({ invocation: { raw: '/at-file', name: 'at-file', args: '', }, }), '', ); expect(result?.type).toBe('submit_prompt'); if (result?.type === 'submit_prompt') { expect(result.content).toEqual([ { text: 'Context from file: file content' }, ]); } }); }); describe('with folder trust enabled', () => { it('loads multiple commands', async () => { const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => true), isTrustedFolder: vi.fn(() => true), } as unknown as Config; const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test1.toml': 'prompt = "Prompt 1"', 'test2.toml': 'prompt = "Prompt 2"', }, }); const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(2); }); it('does not load when folder is not trusted', async () => { const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => true), isTrustedFolder: vi.fn(() => false), } as unknown as Config; const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test1.toml': 'prompt = "Prompt 1"', 'test2.toml': 'prompt = "Prompt 2"', }, }); const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(0); }); }); describe('Aborted signal', () => { it('does not log errors if the signal is aborted', async () => { const controller = new AbortController(); const abortSignal = controller.signal; const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; // Set up mock-fs so that the loader attempts to read a directory. const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test1.toml': 'prompt = "Prompt 1"', }, }); const loader = new FileCommandLoader(mockConfig); // Mock glob to throw an AbortError const abortError = new DOMException('Aborted', 'AbortError'); vi.mocked(glob.glob).mockImplementation(async () => { controller.abort(); // Ensure the signal is aborted when the service checks throw abortError; }); await loader.loadCommands(abortSignal); expect(consoleErrorSpy).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); }); describe('Sanitization', () => { it('sanitizes command names from filenames containing control characters', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test\twith\nnewlines.toml': 'prompt = "Test prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); // Non-alphanumeric characters (except - and .) become underscores expect(commands[0].name).toBe('test_with_newlines'); }); it('truncates excessively long filenames', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const longName = 'a'.repeat(60) + '.toml'; mock({ [userCommandsDir]: { [longName]: 'prompt = "Test prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].name.length).toBe(50); expect(commands[0].name).toBe('a'.repeat(47) + '...'); }); it('sanitizes descriptions containing newlines and ANSI codes', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); // Newlines and tabs become spaces, ANSI is stripped expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text'); }); it('truncates long descriptions', async () => { const userCommandsDir = Storage.getUserCommandsDir(); const longDesc = 'd'.repeat(150); mock({ [userCommandsDir]: { 'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`, }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].description.length).toBe(100); expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); }); ================================================ FILE: packages/cli/src/services/FileCommandLoader.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import toml from '@iarna/toml'; import { glob } from 'glob'; import { z } from 'zod'; import { Storage, coreEvents, type Config } from '@google/gemini-cli-core'; import type { ICommandLoader } from './types.js'; import type { CommandContext, SlashCommand, SlashCommandActionReturn, } from '../ui/commands/types.js'; import { CommandKind } from '../ui/commands/types.js'; import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; import type { IPromptProcessor, PromptPipelineContent, } from './prompt-processors/types.js'; import { SHORTHAND_ARGS_PLACEHOLDER, SHELL_INJECTION_TRIGGER, AT_FILE_INJECTION_TRIGGER, } from './prompt-processors/types.js'; import { ConfirmationRequiredError, ShellProcessor, } from './prompt-processors/shellProcessor.js'; import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; kind: CommandKind; extensionName?: string; extensionId?: string; } /** * Defines the Zod schema for a command definition file. This serves as the * single source of truth for both validation and type inference. */ const TomlCommandDefSchema = z.object({ prompt: z.string({ required_error: "The 'prompt' field is required.", invalid_type_error: "The 'prompt' field must be a string.", }), description: z.string().optional(), }); /** * Discovers and loads custom slash commands from .toml files in both the * user's global config directory and the current project's directory. * * This loader is responsible for: * - Recursively scanning command directories. * - Parsing and validating TOML files. * - Adapting valid definitions into executable SlashCommand objects. * - Handling file system errors and malformed files gracefully. */ export class FileCommandLoader implements ICommandLoader { private readonly projectRoot: string; private readonly folderTrustEnabled: boolean; private readonly isTrustedFolder: boolean; constructor(private readonly config: Config | null) { this.folderTrustEnabled = !!config?.getFolderTrust(); this.isTrustedFolder = !!config?.isTrustedFolder(); this.projectRoot = config?.getProjectRoot() || process.cwd(); } /** * Loads all commands from user, project, and extension directories. * Returns commands in order: user → project → extensions (alphabetically). * * Order is important for conflict resolution in CommandService: * - User/project commands (without extensionName) use "last wins" strategy * - Extension commands (with extensionName) get renamed if conflicts exist * * @param signal An AbortSignal to cancel the loading process. * @returns A promise that resolves to an array of all loaded SlashCommands. */ async loadCommands(signal: AbortSignal): Promise { if (this.folderTrustEnabled && !this.isTrustedFolder) { return []; } const allCommands: SlashCommand[] = []; const globOptions = { nodir: true, dot: true, signal, follow: true, }; // Load commands from each directory const commandDirs = this.getCommandDirectories(); for (const dirInfo of commandDirs) { try { const files = await glob('**/*.toml', { ...globOptions, cwd: dirInfo.path, }); const commandPromises = files.map((file) => this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, dirInfo.kind, dirInfo.extensionName, dirInfo.extensionId, ), ); const commands = (await Promise.all(commandPromises)).filter( (cmd): cmd is SlashCommand => cmd !== null, ); // Add all commands without deduplication allCommands.push(...commands); } catch (error) { if ( !signal.aborted && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (error as { code?: string })?.code !== 'ENOENT' ) { coreEvents.emitFeedback( 'error', `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, error, ); } } } return allCommands; } /** * Get all command directories in order for loading. * User commands → Project commands → Extension commands * This order ensures extension commands can detect all conflicts. */ private getCommandDirectories(): CommandDirectory[] { const dirs: CommandDirectory[] = []; const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands dirs.push({ path: Storage.getUserCommandsDir(), kind: CommandKind.USER_FILE, }); // 2. Project commands dirs.push({ path: storage.getProjectCommandsDir(), kind: CommandKind.WORKSPACE_FILE, }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { const activeExtensions = this.config .getExtensions() .filter((ext) => ext.isActive) .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), kind: CommandKind.EXTENSION_FILE, extensionName: ext.name, extensionId: ext.id, })); dirs.push(...extensionCommandDirs); } return dirs; } /** * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. * @param kind The CommandKind. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, kind: CommandKind, extensionName?: string, extensionId?: string, ): Promise { let fileContent: string; try { fileContent = await fs.readFile(filePath, 'utf-8'); } catch (error: unknown) { coreEvents.emitFeedback( 'error', `[FileCommandLoader] Failed to read file ${filePath}:`, error instanceof Error ? error.message : String(error), ); return null; } let parsed: unknown; try { parsed = toml.parse(fileContent); } catch (error: unknown) { coreEvents.emitFeedback( 'error', `[FileCommandLoader] Failed to parse TOML file ${filePath}:`, error instanceof Error ? error.message : String(error), ); return null; } const validationResult = TomlCommandDefSchema.safeParse(parsed); if (!validationResult.success) { coreEvents.emitFeedback( 'error', `[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`, validationResult.error.flatten(), ); return null; } const validDef = validationResult.data; const relativePathWithExt = path.relative(baseDir, filePath); const relativePath = relativePathWithExt.substring( 0, relativePathWithExt.length - 5, // length of '.toml' ); const baseCommandName = relativePath .split(path.sep) // Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores. // Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts. .map((segment) => { let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_'); // Truncate excessively long segments to prevent UI overflow if (sanitized.length > 50) { sanitized = sanitized.substring(0, 47) + '...'; } return sanitized; }) .join(':'); // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; description = sanitizeForDisplay(description, 100); if (extensionName) { description = `[${extensionName}] ${description}`; } const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); const usesShellInjection = validDef.prompt.includes( SHELL_INJECTION_TRIGGER, ); const usesAtFileInjection = validDef.prompt.includes( AT_FILE_INJECTION_TRIGGER, ); // 1. @-File Injection (Security First). // This runs first to ensure we're not executing shell commands that // could dynamically generate malicious @-paths. if (usesAtFileInjection) { processors.push(new AtFileProcessor(baseCommandName)); } // 2. Argument and Shell Injection. // This runs after file content has been safely injected. if (usesShellInjection || usesArgs) { processors.push(new ShellProcessor(baseCommandName)); } // 3. Default Argument Handling. // Appends the raw invocation if no explicit {{args}} are used. if (!usesArgs) { processors.push(new DefaultArgumentProcessor()); } return { name: baseCommandName, description, kind, extensionName, extensionId, action: async ( context: CommandContext, _args: string, ): Promise => { if (!context.invocation) { coreEvents.emitFeedback( 'error', `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, ); return { type: 'submit_prompt', content: [{ text: validDef.prompt }], // Fallback to unprocessed prompt }; } try { let processedContent: PromptPipelineContent = [ { text: validDef.prompt }, ]; for (const processor of processors) { processedContent = await processor.process( processedContent, context, ); } return { type: 'submit_prompt', content: processedContent, }; } catch (e) { // Check if it's our specific error type if (e instanceof ConfirmationRequiredError) { // Halt and request confirmation from the UI layer. return { type: 'confirm_shell_commands', commandsToConfirm: e.commandsToConfirm, originalInvocation: { raw: context.invocation.raw, }, }; } // Re-throw other errors to be handled by the global error handler. throw e; } }, }; } } ================================================ FILE: packages/cli/src/services/McpPromptLoader.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { McpPromptLoader } from './McpPromptLoader.js'; import type { Config } from '@google/gemini-cli-core'; import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CommandKind, type CommandContext } from '../ui/commands/types.js'; import * as cliCore from '@google/gemini-cli-core'; // Define the mock prompt data at a higher scope const mockPrompt = { name: 'test-prompt', description: 'A test prompt.', serverName: 'test-server', arguments: [ { name: 'name', required: true, description: "The animal's name." }, { name: 'age', required: true, description: "The animal's age." }, { name: 'species', required: true, description: "The animal's species." }, { name: 'enclosure', required: false, description: "The animal's enclosure.", }, { name: 'trail', required: false, description: "The animal's trail." }, ], invoke: vi.fn().mockResolvedValue({ messages: [{ content: { type: 'text', text: 'Hello, world!' } }], }), }; describe('McpPromptLoader', () => { const mockConfig = {} as Config; // Use a beforeEach to set up and clean a spy for each test beforeEach(() => { vi.clearAllMocks(); vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([mockPrompt]); }); // --- `parseArgs` tests remain the same --- describe('parseArgs', () => { it('should handle multi-word positional arguments', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'arg1', required: true }, { name: 'arg2', required: true }, ]; const userArgs = 'hello world'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: 'hello', arg2: 'world' }); }); it('should handle quoted multi-word positional arguments', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'arg1', required: true }, { name: 'arg2', required: true }, ]; const userArgs = '"hello world" foo'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: 'hello world', arg2: 'foo' }); }); it('should handle a single positional argument with multiple words', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; const userArgs = 'hello world'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: 'hello world' }); }); it('should handle escaped quotes in positional arguments', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; const userArgs = '"hello \\"world\\""'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: 'hello "world"' }); }); it('should handle escaped backslashes in positional arguments', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }]; const userArgs = '"hello\\\\world"'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: 'hello\\world' }); }); it('should handle named args followed by positional args', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'named', required: true }, { name: 'pos', required: true }, ]; const userArgs = '--named="value" positional'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ named: 'value', pos: 'positional' }); }); it('should handle positional args followed by named args', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'pos', required: true }, { name: 'named', required: true }, ]; const userArgs = 'positional --named="value"'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ pos: 'positional', named: 'value' }); }); it('should handle positional args interspersed with named args', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'pos1', required: true }, { name: 'named', required: true }, { name: 'pos2', required: true }, ]; const userArgs = 'p1 --named="value" p2'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ pos1: 'p1', named: 'value', pos2: 'p2' }); }); it('should treat an escaped quote at the start as a literal', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'arg1', required: true }, { name: 'arg2', required: true }, ]; const userArgs = '\\"hello world'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ arg1: '"hello', arg2: 'world' }); }); it('should handle a complex mix of args', () => { const loader = new McpPromptLoader(mockConfig); const promptArgs: PromptArgument[] = [ { name: 'pos1', required: true }, { name: 'named1', required: true }, { name: 'pos2', required: true }, { name: 'named2', required: true }, { name: 'pos3', required: true }, ]; const userArgs = 'p1 --named1="value 1" "p2 has spaces" --named2=value2 "p3 \\"with quotes\\""'; const result = loader.parseArgs(userArgs, promptArgs); expect(result).toEqual({ pos1: 'p1', named1: 'value 1', pos2: 'p2 has spaces', named2: 'value2', pos3: 'p3 "with quotes"', }); }); }); describe('loadCommands', () => { const mockConfigWithPrompts = { getMcpClientManager: () => ({ getMcpServers: () => ({ 'test-server': { httpUrl: 'https://test-server.com' }, }), }), } as unknown as Config; it('should load prompts as slash commands', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('test-prompt'); expect(commands[0].description).toBe('A test prompt.'); expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); }); it('should sanitize prompt names by replacing spaces with hyphens', async () => { const mockPromptWithSpaces = { ...mockPrompt, name: 'Prompt Name', }; vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ mockPromptWithSpaces, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('Prompt-Name'); expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); }); it('should trim whitespace from prompt names before sanitizing', async () => { const mockPromptWithWhitespace = { ...mockPrompt, name: ' Prompt Name ', }; vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ mockPromptWithWhitespace, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('Prompt-Name'); expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT); }); it('should handle prompt invocation successfully', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); const action = commands[0].action!; const context = {} as CommandContext; const result = await action(context, 'test-name 123 tiger'); expect(mockPrompt.invoke).toHaveBeenCalledWith({ name: 'test-name', age: '123', species: 'tiger', }); expect(result).toEqual({ type: 'submit_prompt', content: JSON.stringify('Hello, world!'), }); }); it('should return an error for missing required arguments', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); const action = commands[0].action!; const context = {} as CommandContext; const result = await action(context, 'test-name'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing required argument(s): --age, --species', }); }); it('should return an error message if prompt invocation fails', async () => { vi.spyOn(mockPrompt, 'invoke').mockRejectedValue( new Error('Invocation failed!'), ); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands(new AbortController().signal); const action = commands[0].action!; const context = {} as CommandContext; const result = await action(context, 'test-name 123 tiger'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Error: Invocation failed!', }); }); it('should return an empty array if config is not available', async () => { const loader = new McpPromptLoader(null); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toEqual([]); }); describe('autoExecute', () => { it('should set autoExecute to true for prompts with no arguments (undefined)', async () => { vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ { ...mockPrompt, arguments: undefined }, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); expect(commands[0].autoExecute).toBe(true); }); it('should set autoExecute to true for prompts with empty arguments array', async () => { vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ { ...mockPrompt, arguments: [] }, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); expect(commands[0].autoExecute).toBe(true); }); it('should set autoExecute to false for prompts with only optional arguments', async () => { vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ { ...mockPrompt, arguments: [{ name: 'optional', required: false }], }, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); expect(commands[0].autoExecute).toBe(false); }); it('should set autoExecute to false for prompts with required arguments', async () => { vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ { ...mockPrompt, arguments: [{ name: 'required', required: true }], }, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); expect(commands[0].autoExecute).toBe(false); }); }); describe('completion', () => { it('should suggest no arguments when using positional arguments', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = {} as CommandContext; const suggestions = await completion(context, 'test-name 6 tiger'); expect(suggestions).toEqual([]); }); it('should suggest all arguments when none are present', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find ', name: 'find', args: '', }, } as CommandContext; const suggestions = await completion(context, ''); expect(suggestions).toEqual([ '--name="', '--age="', '--species="', '--enclosure="', '--trail="', ]); }); it('should suggest remaining arguments when some are present', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --name="test-name" --age="6" ', name: 'find', args: '--name="test-name" --age="6"', }, } as CommandContext; const suggestions = await completion(context, ''); expect(suggestions).toEqual([ '--species="', '--enclosure="', '--trail="', ]); }); it('should suggest no arguments when all are present', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = {} as CommandContext; const suggestions = await completion( context, '--name="test-name" --age="6" --species="tiger" --enclosure="Tiger Den" --trail="Jungle"', ); expect(suggestions).toEqual([]); }); it('should suggest nothing for prompts with no arguments', async () => { // Temporarily override the mock to return a prompt with no args vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([ { ...mockPrompt, arguments: [] }, ]); const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = {} as CommandContext; const suggestions = await completion(context, ''); expect(suggestions).toEqual([]); }); it('should suggest arguments matching a partial argument', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --s', name: 'find', args: '--s', }, } as CommandContext; const suggestions = await completion(context, '--s'); expect(suggestions).toEqual(['--species="']); }); it('should suggest arguments even when a partial argument is parsed as a value', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --name="test" --a', name: 'find', args: '--name="test" --a', }, } as CommandContext; const suggestions = await completion(context, '--a'); expect(suggestions).toEqual(['--age="']); }); it('should auto-close the quote for a named argument value', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --name="test', name: 'find', args: '--name="test', }, } as CommandContext; const suggestions = await completion(context, '--name="test'); expect(suggestions).toEqual(['--name="test"']); }); it('should auto-close the quote for an empty named argument value', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --name="', name: 'find', args: '--name="', }, } as CommandContext; const suggestions = await completion(context, '--name="'); expect(suggestions).toEqual(['--name=""']); }); it('should not add a quote if already present', async () => { const loader = new McpPromptLoader(mockConfigWithPrompts); const commands = await loader.loadCommands( new AbortController().signal, ); const completion = commands[0].completion!; const context = { invocation: { raw: '/find --name="test"', name: 'find', args: '--name="test"', }, } as CommandContext; const suggestions = await completion(context, '--name="test"'); expect(suggestions).toEqual([]); }); }); }); }); ================================================ FILE: packages/cli/src/services/McpPromptLoader.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { getErrorMessage, getMCPServerPrompts, type Config, } from '@google/gemini-cli-core'; import { CommandKind, type CommandContext, type SlashCommand, type SlashCommandActionReturn, } from '../ui/commands/types.js'; import type { ICommandLoader } from './types.js'; import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; /** * Discovers and loads executable slash commands from prompts exposed by * Model-Context-Protocol (MCP) servers. */ export class McpPromptLoader implements ICommandLoader { constructor(private readonly config: Config | null) {} /** * Loads all available prompts from all configured MCP servers and adapts * them into executable SlashCommand objects. * * @param _signal An AbortSignal (unused for this synchronous loader). * @returns A promise that resolves to an array of loaded SlashCommands. */ loadCommands(_signal: AbortSignal): Promise { const promptCommands: SlashCommand[] = []; if (!this.config) { return Promise.resolve([]); } const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; for (const serverName in mcpServers) { const prompts = getMCPServerPrompts(this.config, serverName) || []; for (const prompt of prompts) { // Sanitize prompt names to ensure they are valid slash commands (e.g. "Prompt Name" -> "Prompt-Name") const commandName = `${prompt.name}`.trim().replace(/\s+/g, '-'); const newPromptCommand: SlashCommand = { name: commandName, description: prompt.description || `Invoke prompt ${prompt.name}`, kind: CommandKind.MCP_PROMPT, mcpServerName: serverName, autoExecute: !prompt.arguments || prompt.arguments.length === 0, subCommands: [ { name: 'help', description: 'Show help for this prompt', kind: CommandKind.MCP_PROMPT, action: async (): Promise => { if (!prompt.arguments || prompt.arguments.length === 0) { return { type: 'message', messageType: 'info', content: `Prompt "${prompt.name}" has no arguments.`, }; } let helpMessage = `Arguments for "${prompt.name}":\n\n`; if (prompt.arguments && prompt.arguments.length > 0) { helpMessage += `You can provide arguments by name (e.g., --argName="value") or by position.\n\n`; helpMessage += `e.g., ${prompt.name} ${prompt.arguments?.map((_) => `"foo"`)} is equivalent to ${prompt.name} ${prompt.arguments?.map((arg) => `--${arg.name}="foo"`)}\n\n`; } for (const arg of prompt.arguments) { helpMessage += ` --${arg.name}\n`; if (arg.description) { helpMessage += ` ${arg.description}\n`; } helpMessage += ` (required: ${ arg.required ? 'yes' : 'no' })\n\n`; } return { type: 'message', messageType: 'info', content: helpMessage, }; }, }, ], action: async ( context: CommandContext, args: string, ): Promise => { if (!this.config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const promptInputs = this.parseArgs(args, prompt.arguments); if (promptInputs instanceof Error) { return { type: 'message', messageType: 'error', content: promptInputs.message, }; } try { const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; const mcpServerConfig = mcpServers[serverName]; if (!mcpServerConfig) { return { type: 'message', messageType: 'error', content: `MCP server config not found for '${serverName}'.`, }; } const result = await prompt.invoke(promptInputs); if (result['error']) { return { type: 'message', messageType: 'error', content: `Error invoking prompt: ${result['error']}`, }; } const maybeContent = result.messages?.[0]?.content; if (maybeContent.type !== 'text') { return { type: 'message', messageType: 'error', content: 'Received an empty or invalid prompt response from the server.', }; } return { type: 'submit_prompt', content: JSON.stringify(maybeContent.text), }; } catch (error) { return { type: 'message', messageType: 'error', content: `Error: ${getErrorMessage(error)}`, }; } }, completion: async ( commandContext: CommandContext, partialArg: string, ) => { const invocation = commandContext.invocation; if (!prompt || !prompt.arguments || !invocation) { return []; } const indexOfFirstSpace = invocation.raw.indexOf(' ') + 1; let promptInputs = indexOfFirstSpace === 0 ? {} : this.parseArgs( invocation.raw.substring(indexOfFirstSpace), prompt.arguments, ); if (promptInputs instanceof Error) { promptInputs = {}; } const providedArgNames = Object.keys(promptInputs); const unusedArguments = prompt.arguments .filter((arg) => { // If this arguments is not in the prompt inputs // add it to unusedArguments if (!providedArgNames.includes(arg.name)) { return true; } // The parseArgs method assigns the value // at the end of the prompt as a final value // The argument should still be suggested // Example /add --numberOne="34" --num // numberTwo would be assigned a value of --num // numberTwo should still be considered unused const argValue = promptInputs[arg.name]; return argValue === partialArg; }) .map((argument) => `--${argument.name}="`) || []; const exactlyMatchingArgumentAtTheEnd = prompt.arguments .map((argument) => `--${argument.name}="`) .filter((flagArgument) => { const regex = new RegExp(`${flagArgument}[^"]*$`); return regex.test(invocation.raw); }); if (exactlyMatchingArgumentAtTheEnd.length === 1) { if (exactlyMatchingArgumentAtTheEnd[0] === partialArg) { return [`${partialArg}"`]; } if (partialArg.endsWith('"')) { return [partialArg]; } return [`${partialArg}"`]; } const matchingArguments = unusedArguments.filter((flagArgument) => flagArgument.startsWith(partialArg), ); return matchingArguments; }, }; promptCommands.push(newPromptCommand); } } return Promise.resolve(promptCommands); } /** * Parses the `userArgs` string representing the prompt arguments (all the text * after the command) into a record matching the shape of the `promptArgs`. * * @param userArgs * @param promptArgs * @returns A record of the parsed arguments * @visibleForTesting */ parseArgs( userArgs: string, promptArgs: PromptArgument[] | undefined, ): Record | Error { const argValues: { [key: string]: string } = {}; const promptInputs: Record = {}; // arg parsing: --key="value" or --key=value const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g; let match; let lastIndex = 0; const positionalParts: string[] = []; while ((match = namedArgRegex.exec(userArgs)) !== null) { const key = match[1]; // Extract the quoted or unquoted argument and remove escape chars. const value = (match[2] ?? match[3]).replace(/\\(.)/g, '$1'); argValues[key] = value; // Capture text between matches as potential positional args if (match.index > lastIndex) { positionalParts.push(userArgs.substring(lastIndex, match.index)); } lastIndex = namedArgRegex.lastIndex; } // Capture any remaining text after the last named arg if (lastIndex < userArgs.length) { positionalParts.push(userArgs.substring(lastIndex)); } const positionalArgsString = positionalParts.join('').trim(); // extracts either quoted strings or non-quoted sequences of non-space characters. const positionalArgRegex = /(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g; const positionalArgs: string[] = []; while ((match = positionalArgRegex.exec(positionalArgsString)) !== null) { // Extract the quoted or unquoted argument and remove escape chars. positionalArgs.push((match[1] ?? match[2]).replace(/\\(.)/g, '$1')); } if (!promptArgs) { return promptInputs; } for (const arg of promptArgs) { if (argValues[arg.name]) { promptInputs[arg.name] = argValues[arg.name]; } } const unfilledArgs = promptArgs.filter( (arg) => arg.required && !promptInputs[arg.name], ); if (unfilledArgs.length === 1) { // If we have only one unfilled arg, we don't require quotes we just // join all the given arguments together as if they were quoted. promptInputs[unfilledArgs[0].name] = positionalArgs.join(' '); } else { const missingArgs: string[] = []; for (let i = 0; i < unfilledArgs.length; i++) { if (positionalArgs.length > i) { promptInputs[unfilledArgs[i].name] = positionalArgs[i]; } else { missingArgs.push(unfilledArgs[i].name); } } if (missingArgs.length > 0) { const missingArgNames = missingArgs .map((name) => `--${name}`) .join(', '); return new Error(`Missing required argument(s): ${missingArgNames}`); } } return promptInputs; } } ================================================ FILE: packages/cli/src/services/SkillCommandLoader.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { SkillCommandLoader } from './SkillCommandLoader.js'; import { CommandKind } from '../ui/commands/types.js'; import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; describe('SkillCommandLoader', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockConfig: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSkillManager: any; beforeEach(() => { mockSkillManager = { getDisplayableSkills: vi.fn(), isAdminEnabled: vi.fn().mockReturnValue(true), }; mockConfig = { isSkillsSupportEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue(mockSkillManager), }; }); it('should return an empty array if skills support is disabled', async () => { mockConfig.isSkillsSupportEnabled.mockReturnValue(false); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toEqual([]); }); it('should return an empty array if SkillManager is missing', async () => { mockConfig.getSkillManager.mockReturnValue(null); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toEqual([]); }); it('should return an empty array if skills are admin-disabled', async () => { mockSkillManager.isAdminEnabled.mockReturnValue(false); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toEqual([]); }); it('should load skills as slash commands', async () => { const mockSkills = [ { name: 'skill1', description: 'Description 1' }, { name: 'skill2', description: '' }, ]; mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands).toHaveLength(2); expect(commands[0]).toMatchObject({ name: 'skill1', description: 'Description 1', kind: CommandKind.SKILL, autoExecute: true, }); expect(commands[1]).toMatchObject({ name: 'skill2', description: 'Activate the skill2 skill', kind: CommandKind.SKILL, autoExecute: true, }); }); it('should return a tool action when a skill command is executed', async () => { const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); // eslint-disable-next-line @typescript-eslint/no-explicit-any const actionResult = await commands[0].action!({} as any, ''); expect(actionResult).toEqual({ type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, toolArgs: { name: 'test-skill' }, postSubmitPrompt: undefined, }); }); it('should return a tool action with postSubmitPrompt when args are provided', async () => { const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); // eslint-disable-next-line @typescript-eslint/no-explicit-any const actionResult = await commands[0].action!({} as any, 'hello world'); expect(actionResult).toEqual({ type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, toolArgs: { name: 'test-skill' }, postSubmitPrompt: 'hello world', }); }); it('should sanitize skill names with spaces', async () => { const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }]; mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands[0].name).toBe('my-awesome-skill'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const actionResult = (await commands[0].action!({} as any, '')) as any; expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' }); }); it('should propagate extensionName to the generated slash command', async () => { const mockSkills = [ { name: 'skill1', description: 'desc', extensionName: 'ext1' }, ]; mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); const loader = new SkillCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); expect(commands[0].extensionName).toBe('ext1'); }); }); ================================================ FILE: packages/cli/src/services/SkillCommandLoader.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { type ICommandLoader } from './types.js'; /** * Loads Agent Skills as slash commands. */ export class SkillCommandLoader implements ICommandLoader { constructor(private config: Config | null) {} /** * Discovers all available skills from the SkillManager and converts * them into executable slash commands. * * @param _signal An AbortSignal (unused for this synchronous loader). * @returns A promise that resolves to an array of `SlashCommand` objects. */ async loadCommands(_signal: AbortSignal): Promise { if (!this.config || !this.config.isSkillsSupportEnabled()) { return []; } const skillManager = this.config.getSkillManager(); if (!skillManager || !skillManager.isAdminEnabled()) { return []; } // Convert all displayable skills into slash commands. const skills = skillManager.getDisplayableSkills(); return skills.map((skill) => { const commandName = skill.name.trim().replace(/\s+/g, '-'); return { name: commandName, description: skill.description || `Activate the ${skill.name} skill`, kind: CommandKind.SKILL, autoExecute: true, extensionName: skill.extensionName, action: async (_context, args) => ({ type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, toolArgs: { name: skill.name }, postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined, }), }; }); } } ================================================ FILE: packages/cli/src/services/SlashCommandConflictHandler.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SlashCommandConflictHandler } from './SlashCommandConflictHandler.js'; import { coreEvents, CoreEvent, type SlashCommandConflictsPayload, type SlashCommandConflict, } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { on: vi.fn(), off: vi.fn(), emitFeedback: vi.fn(), }, }; }); describe('SlashCommandConflictHandler', () => { let handler: SlashCommandConflictHandler; /** * Helper to find and invoke the registered conflict event listener. */ const simulateEvent = (conflicts: SlashCommandConflict[]) => { const callback = vi .mocked(coreEvents.on) .mock.calls.find( (call) => call[0] === CoreEvent.SlashCommandConflicts, )![1] as (payload: SlashCommandConflictsPayload) => void; callback({ conflicts }); }; beforeEach(() => { vi.useFakeTimers(); handler = new SlashCommandConflictHandler(); handler.start(); }); afterEach(() => { handler.stop(); vi.clearAllMocks(); vi.useRealTimers(); }); it('should listen for conflict events on start', () => { expect(coreEvents.on).toHaveBeenCalledWith( CoreEvent.SlashCommandConflicts, expect.any(Function), ); }); it('should display a descriptive message for a single extension conflict', () => { simulateEvent([ { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", ); }); it('should display a descriptive message for a single MCP conflict', () => { simulateEvent([ { name: 'pickle', renamedTo: 'test-server.pickle', loserMcpServerName: 'test-server', loserKind: CommandKind.MCP_PROMPT, winnerExtensionName: 'pickle-rick', winnerKind: CommandKind.EXTENSION_FILE, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "MCP server 'test-server' command '/pickle' was renamed to '/test-server.pickle' because it conflicts with extension 'pickle-rick' command.", ); }); it('should group multiple conflicts for the same command name', () => { simulateEvent([ { name: 'launch', renamedTo: 'user.launch', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.WORKSPACE_FILE, }, { name: 'launch', renamedTo: 'workspace.launch', loserKind: CommandKind.WORKSPACE_FILE, winnerKind: CommandKind.USER_FILE, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', `Conflicts detected for command '/launch': - User command '/launch' was renamed to '/user.launch' - Workspace command '/launch' was renamed to '/workspace.launch'`, ); }); it('should debounce multiple events within the flush window', () => { simulateEvent([ { name: 'a', renamedTo: 'user.a', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(200); simulateEvent([ { name: 'b', renamedTo: 'user.b', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(600); // Should emit two feedbacks (one for each unique command name) expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); }); it('should deduplicate already notified conflicts', () => { const conflict = { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }; simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); vi.mocked(coreEvents.emitFeedback).mockClear(); simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); }); it('should display a descriptive message for a skill conflict', () => { simulateEvent([ { name: 'chat', renamedTo: 'google-workspace.chat', loserExtensionName: 'google-workspace', loserKind: CommandKind.SKILL, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.", ); }); }); ================================================ FILE: packages/cli/src/services/SlashCommandConflictHandler.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { coreEvents, CoreEvent, type SlashCommandConflictsPayload, type SlashCommandConflict, } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; /** * Handles slash command conflict events and provides user feedback. * * This handler batches multiple conflict events into a single notification * block per command name to avoid UI clutter during startup or incremental loading. */ export class SlashCommandConflictHandler { private notifiedConflicts = new Set(); private pendingConflicts: SlashCommandConflict[] = []; private flushTimeout: ReturnType | null = null; constructor() { this.handleConflicts = this.handleConflicts.bind(this); } start() { coreEvents.on(CoreEvent.SlashCommandConflicts, this.handleConflicts); } stop() { coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts); if (this.flushTimeout) { clearTimeout(this.flushTimeout); this.flushTimeout = null; } } private handleConflicts(payload: SlashCommandConflictsPayload) { const newConflicts = payload.conflicts.filter((c) => { // Use a unique key to prevent duplicate notifications for the same conflict const sourceId = c.loserExtensionName || c.loserMcpServerName || c.loserKind; const key = `${c.name}:${sourceId}:${c.renamedTo}`; if (this.notifiedConflicts.has(key)) { return false; } this.notifiedConflicts.add(key); return true; }); if (newConflicts.length > 0) { this.pendingConflicts.push(...newConflicts); this.scheduleFlush(); } } private scheduleFlush() { if (this.flushTimeout) { clearTimeout(this.flushTimeout); } // Use a trailing debounce to capture staggered reloads during startup this.flushTimeout = setTimeout(() => this.flush(), 500); } private flush() { this.flushTimeout = null; const conflicts = [...this.pendingConflicts]; this.pendingConflicts = []; if (conflicts.length === 0) { return; } // Group conflicts by their original command name const grouped = new Map(); for (const c of conflicts) { const list = grouped.get(c.name) ?? []; list.push(c); grouped.set(c.name, list); } for (const [name, commandConflicts] of grouped) { if (commandConflicts.length > 1) { this.emitGroupedFeedback(name, commandConflicts); } else { this.emitSingleFeedback(commandConflicts[0]); } } } /** * Emits a grouped notification for multiple conflicts sharing the same name. */ private emitGroupedFeedback( name: string, conflicts: SlashCommandConflict[], ): void { const messages = conflicts .map((c) => { const source = this.getSourceDescription( c.loserExtensionName, c.loserKind, c.loserMcpServerName, ); return `- ${this.capitalize(source)} '/${c.name}' was renamed to '/${c.renamedTo}'`; }) .join('\n'); coreEvents.emitFeedback( 'info', `Conflicts detected for command '/${name}':\n${messages}`, ); } /** * Emits a descriptive notification for a single command conflict. */ private emitSingleFeedback(c: SlashCommandConflict): void { const loserSource = this.getSourceDescription( c.loserExtensionName, c.loserKind, c.loserMcpServerName, ); const winnerSource = this.getSourceDescription( c.winnerExtensionName, c.winnerKind, c.winnerMcpServerName, ); coreEvents.emitFeedback( 'info', `${this.capitalize(loserSource)} '/${c.name}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`, ); } private capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } /** * Returns a human-readable description of a command's source. */ private getSourceDescription( extensionName?: string, kind?: string, mcpServerName?: string, ): string { switch (kind) { case CommandKind.EXTENSION_FILE: return extensionName ? `extension '${extensionName}' command` : 'extension command'; case CommandKind.SKILL: return extensionName ? `extension '${extensionName}' skill` : 'skill command'; case CommandKind.MCP_PROMPT: return mcpServerName ? `MCP server '${mcpServerName}' command` : 'MCP server command'; case CommandKind.USER_FILE: return 'user command'; case CommandKind.WORKSPACE_FILE: return 'workspace command'; case CommandKind.BUILT_IN: return 'built-in command'; default: return 'existing command'; } } } ================================================ FILE: packages/cli/src/services/SlashCommandResolver.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { SlashCommandResolver } from './SlashCommandResolver.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ name, description: `Description for ${name}`, kind, action: vi.fn(), }); describe('SlashCommandResolver', () => { describe('resolve', () => { it('should return all commands when there are no conflicts', () => { const cmdA = createMockCommand('a', CommandKind.BUILT_IN); const cmdB = createMockCommand('b', CommandKind.USER_FILE); const { finalCommands, conflicts } = SlashCommandResolver.resolve([ cmdA, cmdB, ]); expect(finalCommands).toHaveLength(2); expect(conflicts).toHaveLength(0); }); it('should rename extension commands when they conflict with built-in', () => { const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); const extension = { ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), extensionName: 'firebase', }; const { finalCommands, conflicts } = SlashCommandResolver.resolve([ builtin, extension, ]); expect(finalCommands.map((c) => c.name)).toContain('deploy'); expect(finalCommands.map((c) => c.name)).toContain('firebase.deploy'); expect(conflicts).toHaveLength(1); }); it('should prefix both user and workspace commands when they conflict', () => { const userCmd = createMockCommand('sync', CommandKind.USER_FILE); const workspaceCmd = createMockCommand( 'sync', CommandKind.WORKSPACE_FILE, ); const { finalCommands, conflicts } = SlashCommandResolver.resolve([ userCmd, workspaceCmd, ]); const names = finalCommands.map((c) => c.name); expect(names).not.toContain('sync'); expect(names).toContain('user.sync'); expect(names).toContain('workspace.sync'); expect(conflicts).toHaveLength(1); expect(conflicts[0].losers).toHaveLength(2); // Both are considered losers }); it('should prefix file commands but keep built-in names during conflicts', () => { const builtin = createMockCommand('help', CommandKind.BUILT_IN); const user = createMockCommand('help', CommandKind.USER_FILE); const { finalCommands } = SlashCommandResolver.resolve([builtin, user]); const names = finalCommands.map((c) => c.name); expect(names).toContain('help'); expect(names).toContain('user.help'); }); it('should prefix both commands when MCP and user file conflict', () => { const mcp = { ...createMockCommand('test', CommandKind.MCP_PROMPT), mcpServerName: 'test-server', }; const user = createMockCommand('test', CommandKind.USER_FILE); const { finalCommands } = SlashCommandResolver.resolve([mcp, user]); const names = finalCommands.map((c) => c.name); expect(names).not.toContain('test'); expect(names).toContain('test-server.test'); expect(names).toContain('user.test'); }); it('should prefix MCP commands with server name when they conflict with built-in', () => { const builtin = createMockCommand('help', CommandKind.BUILT_IN); const mcp = { ...createMockCommand('help', CommandKind.MCP_PROMPT), mcpServerName: 'test-server', }; const { finalCommands } = SlashCommandResolver.resolve([builtin, mcp]); const names = finalCommands.map((c) => c.name); expect(names).toContain('help'); expect(names).toContain('test-server.help'); }); it('should prefix both MCP commands when they conflict with each other', () => { const mcp1 = { ...createMockCommand('test', CommandKind.MCP_PROMPT), mcpServerName: 'server1', }; const mcp2 = { ...createMockCommand('test', CommandKind.MCP_PROMPT), mcpServerName: 'server2', }; const { finalCommands } = SlashCommandResolver.resolve([mcp1, mcp2]); const names = finalCommands.map((c) => c.name); expect(names).not.toContain('test'); expect(names).toContain('server1.test'); expect(names).toContain('server2.test'); }); it('should favor the last built-in command silently during conflicts', () => { const builtin1 = { ...createMockCommand('help', CommandKind.BUILT_IN), description: 'first', }; const builtin2 = { ...createMockCommand('help', CommandKind.BUILT_IN), description: 'second', }; const { finalCommands } = SlashCommandResolver.resolve([ builtin1, builtin2, ]); expect(finalCommands).toHaveLength(1); expect(finalCommands[0].description).toBe('second'); }); it('should fallback to numeric suffixes when both prefix and kind-based prefix are missing', () => { const cmd1 = createMockCommand('test', CommandKind.BUILT_IN); const cmd2 = { ...createMockCommand('test', 'unknown' as CommandKind), }; const { finalCommands } = SlashCommandResolver.resolve([cmd1, cmd2]); const names = finalCommands.map((c) => c.name); expect(names).toContain('test'); expect(names).toContain('test1'); }); it('should apply numeric suffixes when renames also conflict', () => { const user1 = createMockCommand('deploy', CommandKind.USER_FILE); const user2 = createMockCommand('gcp.deploy', CommandKind.USER_FILE); const extension = { ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), extensionName: 'gcp', }; const { finalCommands } = SlashCommandResolver.resolve([ user1, user2, extension, ]); expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined(); }); it('should prefix skills with extension name when they conflict with built-in', () => { const builtin = createMockCommand('chat', CommandKind.BUILT_IN); const skill = { ...createMockCommand('chat', CommandKind.SKILL), extensionName: 'google-workspace', }; const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); const names = finalCommands.map((c) => c.name); expect(names).toContain('chat'); expect(names).toContain('google-workspace.chat'); }); it('should NOT prefix skills with "skill" when extension name is missing', () => { const builtin = createMockCommand('chat', CommandKind.BUILT_IN); const skill = createMockCommand('chat', CommandKind.SKILL); const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); const names = finalCommands.map((c) => c.name); expect(names).toContain('chat'); expect(names).toContain('chat1'); }); }); }); ================================================ FILE: packages/cli/src/services/SlashCommandResolver.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import type { CommandConflict } from './types.js'; /** * Internal registry to track commands and conflicts during resolution. */ class CommandRegistry { readonly commandMap = new Map(); readonly conflictsMap = new Map(); readonly firstEncounters = new Map(); get finalCommands(): SlashCommand[] { return Array.from(this.commandMap.values()); } get conflicts(): CommandConflict[] { return Array.from(this.conflictsMap.values()); } } /** * Resolves name conflicts among slash commands. * * Rules: * 1. Built-in commands always keep the original name. * 2. All other types are prefixed with their source name (e.g. user.name). * 3. If multiple non-built-in commands conflict, all of them are renamed. */ export class SlashCommandResolver { /** * Orchestrates conflict resolution by applying renaming rules to ensures * every command has a unique name. */ static resolve(allCommands: SlashCommand[]): { finalCommands: SlashCommand[]; conflicts: CommandConflict[]; } { const registry = new CommandRegistry(); for (const cmd of allCommands) { const originalName = cmd.name; let finalName = originalName; if (registry.firstEncounters.has(originalName)) { // We've already seen a command with this name, so resolve the conflict. finalName = this.handleConflict(cmd, registry); } else { // Track the first claimant to report them as the conflict reason later. registry.firstEncounters.set(originalName, cmd); } // Store under final name, ensuring the command object reflects it. registry.commandMap.set(finalName, { ...cmd, name: finalName, }); } return { finalCommands: registry.finalCommands, conflicts: registry.conflicts, }; } /** * Resolves a name collision by deciding which command keeps the name and which is renamed. * * @param incoming The command currently being processed that has a name collision. * @param registry The internal state of the resolution process. * @returns The final name to be assigned to the `incoming` command. */ private static handleConflict( incoming: SlashCommand, registry: CommandRegistry, ): string { const collidingName = incoming.name; const originalClaimant = registry.firstEncounters.get(collidingName)!; // Incoming built-in takes priority. Prefix any existing owner. if (incoming.kind === CommandKind.BUILT_IN) { this.prefixExistingCommand(collidingName, incoming, registry); return collidingName; } // Incoming non-built-in is renamed to its source-prefixed version. const renamedName = this.getRenamedName( incoming.name, this.getPrefix(incoming), registry.commandMap, ); this.trackConflict( registry.conflictsMap, collidingName, originalClaimant, incoming, renamedName, ); // Prefix current owner as well if it isn't a built-in. this.prefixExistingCommand(collidingName, incoming, registry); return renamedName; } /** * Safely renames the command currently occupying a name in the registry. * * @param name The name of the command to prefix. * @param reason The incoming command that is causing the prefixing. * @param registry The internal state of the resolution process. */ private static prefixExistingCommand( name: string, reason: SlashCommand, registry: CommandRegistry, ): void { const currentOwner = registry.commandMap.get(name); // Only non-built-in commands can be prefixed. if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) { return; } // Determine the new name for the owner using its source prefix. const renamedName = this.getRenamedName( currentOwner.name, this.getPrefix(currentOwner), registry.commandMap, ); // Update the registry: remove the old name and add the owner under the new name. registry.commandMap.delete(name); const renamedOwner = { ...currentOwner, name: renamedName }; registry.commandMap.set(renamedName, renamedOwner); // Record the conflict so the user can be notified of the prefixing. this.trackConflict( registry.conflictsMap, name, reason, currentOwner, renamedName, ); } /** * Generates a unique name using numeric suffixes if needed. */ private static getRenamedName( name: string, prefix: string | undefined, commandMap: Map, ): string { const base = prefix ? `${prefix}.${name}` : name; let renamedName = base; let suffix = 1; while (commandMap.has(renamedName)) { renamedName = `${base}${suffix}`; suffix++; } return renamedName; } /** * Returns a suitable prefix for a conflicting command. */ private static getPrefix(cmd: SlashCommand): string | undefined { switch (cmd.kind) { case CommandKind.EXTENSION_FILE: case CommandKind.SKILL: return cmd.extensionName; case CommandKind.MCP_PROMPT: return cmd.mcpServerName; case CommandKind.USER_FILE: return 'user'; case CommandKind.WORKSPACE_FILE: return 'workspace'; default: return undefined; } } /** * Logs a conflict event. */ private static trackConflict( conflictsMap: Map, originalName: string, reason: SlashCommand, displacedCommand: SlashCommand, renamedTo: string, ) { if (!conflictsMap.has(originalName)) { conflictsMap.set(originalName, { name: originalName, losers: [], }); } conflictsMap.get(originalName)!.losers.push({ command: displacedCommand, renamedTo, reason, }); } } ================================================ FILE: packages/cli/src/services/prompt-processors/argumentProcessor.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { DefaultArgumentProcessor } from './argumentProcessor.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { describe, it, expect } from 'vitest'; describe('Argument Processors', () => { describe('DefaultArgumentProcessor', () => { const processor = new DefaultArgumentProcessor(); it('should append the full command if args are provided', async () => { const prompt = [{ text: 'Parse the command.' }]; const context = createMockCommandContext({ invocation: { raw: '/mycommand arg1 "arg two"', name: 'mycommand', args: 'arg1 "arg two"', }, }); const result = await processor.process(prompt, context); expect(result).toEqual([ { text: 'Parse the command.\n\n/mycommand arg1 "arg two"' }, ]); }); it('should NOT append the full command if no args are provided', async () => { const prompt = [{ text: 'Parse the command.' }]; const context = createMockCommandContext({ invocation: { raw: '/mycommand', name: 'mycommand', args: '', }, }); const result = await processor.process(prompt, context); expect(result).toEqual([{ text: 'Parse the command.' }]); }); }); }); ================================================ FILE: packages/cli/src/services/prompt-processors/argumentProcessor.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { appendToLastTextPart } from '@google/gemini-cli-core'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; import type { CommandContext } from '../../ui/commands/types.js'; /** * Appends the user's full command invocation to the prompt if arguments are * provided, allowing the model to perform its own argument parsing. * * This processor is only used if the prompt does NOT contain {{args}}. */ export class DefaultArgumentProcessor implements IPromptProcessor { async process( prompt: PromptPipelineContent, context: CommandContext, ): Promise { if (context.invocation?.args) { return appendToLastTextPart(prompt, context.invocation.raw); } return prompt; } } ================================================ FILE: packages/cli/src/services/prompt-processors/atFileProcessor.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { type CommandContext } from '../../ui/commands/types.js'; import { AtFileProcessor } from './atFileProcessor.js'; import { MessageType } from '../../ui/types.js'; import type { Config } from '@google/gemini-cli-core'; import type { PartUnion } from '@google/genai'; // Mock the core dependency const mockReadPathFromWorkspace = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, readPathFromWorkspace: mockReadPathFromWorkspace, }; }); describe('AtFileProcessor', () => { let context: CommandContext; let mockConfig: Config; beforeEach(() => { vi.clearAllMocks(); mockConfig = { // The processor only passes the config through, so we don't need a full mock. get config() { return this; }, } as unknown as Config; context = createMockCommandContext({ services: { agentContext: mockConfig, }, }); // Default mock success behavior: return content wrapped in a text part. mockReadPathFromWorkspace.mockImplementation( async (path: string): Promise => [ { text: `content of ${path}` }, ], ); }); it('should not change the prompt if no @{ trigger is present', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'This is a simple prompt.' }]; const result = await processor.process(prompt, context); expect(result).toEqual(prompt); expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); }); it('should not change the prompt if config service is missing', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const result = await processor.process(prompt, contextWithoutConfig); expect(result).toEqual(prompt); expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); }); describe('Parsing Logic', () => { it('should replace a single valid @{path/to/file.txt} placeholder', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [ { text: 'Analyze this file: @{path/to/file.txt}' }, ]; const result = await processor.process(prompt, context); expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( 'path/to/file.txt', mockConfig, ); expect(result).toEqual([ { text: 'Analyze this file: ' }, { text: 'content of path/to/file.txt' }, ]); }); it('should replace multiple different @{...} placeholders', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [ { text: 'Compare @{file1.js} with @{file2.js}' }, ]; const result = await processor.process(prompt, context); expect(mockReadPathFromWorkspace).toHaveBeenCalledTimes(2); expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( 'file1.js', mockConfig, ); expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( 'file2.js', mockConfig, ); expect(result).toEqual([ { text: 'Compare ' }, { text: 'content of file1.js' }, { text: ' with ' }, { text: 'content of file2.js' }, ]); }); it('should handle placeholders at the beginning, middle, and end', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [ { text: '@{start.txt} in the @{middle.txt} and @{end.txt}' }, ]; const result = await processor.process(prompt, context); expect(result).toEqual([ { text: 'content of start.txt' }, { text: ' in the ' }, { text: 'content of middle.txt' }, { text: ' and ' }, { text: 'content of end.txt' }, ]); }); it('should correctly parse paths that contain balanced braces', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [ { text: 'Analyze @{path/with/{braces}/file.txt}' }, ]; const result = await processor.process(prompt, context); expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( 'path/with/{braces}/file.txt', mockConfig, ); expect(result).toEqual([ { text: 'Analyze ' }, { text: 'content of path/with/{braces}/file.txt' }, ]); }); it('should throw an error if the prompt contains an unclosed trigger', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'Hello @{world' }]; // The new parser throws an error for unclosed injections. await expect(processor.process(prompt, context)).rejects.toThrow( /Unclosed injection/, ); }); }); describe('Integration and Error Handling', () => { it('should leave the placeholder unmodified if readPathFromWorkspace throws', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [ { text: 'Analyze @{not-found.txt} and @{good-file.txt}' }, ]; mockReadPathFromWorkspace.mockImplementation(async (path: string) => { if (path === 'not-found.txt') { throw new Error('File not found'); } return [{ text: `content of ${path}` }]; }); const result = await processor.process(prompt, context); expect(result).toEqual([ { text: 'Analyze ' }, { text: '@{not-found.txt}' }, // Placeholder is preserved as a text part { text: ' and ' }, { text: 'content of good-file.txt' }, ]); }); }); describe('UI Feedback', () => { it('should call ui.addItem with an ERROR on failure', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'Analyze @{bad-file.txt}' }]; mockReadPathFromWorkspace.mockRejectedValue(new Error('Access denied')); await processor.process(prompt, context); expect(context.ui.addItem).toHaveBeenCalledTimes(1); expect(context.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, text: "Failed to inject content for '@{bad-file.txt}': Access denied", }, expect.any(Number), ); }); it('should call ui.addItem with a WARNING if the file was ignored', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'Analyze @{ignored.txt}' }]; // Simulate an ignored file by returning an empty array. mockReadPathFromWorkspace.mockResolvedValue([]); const result = await processor.process(prompt, context); // The placeholder should be removed, resulting in only the prefix. expect(result).toEqual([{ text: 'Analyze ' }]); expect(context.ui.addItem).toHaveBeenCalledTimes(1); expect(context.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: "File '@{ignored.txt}' was ignored by .gitignore or .geminiignore and was not included in the prompt.", }, expect.any(Number), ); }); it('should NOT call ui.addItem on success', async () => { const processor = new AtFileProcessor(); const prompt: PartUnion[] = [{ text: 'Analyze @{good-file.txt}' }]; await processor.process(prompt, context); expect(context.ui.addItem).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: packages/cli/src/services/prompt-processors/atFileProcessor.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger, flatMapTextParts, readPathFromWorkspace, } from '@google/gemini-cli-core'; import type { CommandContext } from '../../ui/commands/types.js'; import { MessageType } from '../../ui/types.js'; import { AT_FILE_INJECTION_TRIGGER, type IPromptProcessor, type PromptPipelineContent, } from './types.js'; import { extractInjections } from './injectionParser.js'; export class AtFileProcessor implements IPromptProcessor { constructor(private readonly commandName?: string) {} async process( input: PromptPipelineContent, context: CommandContext, ): Promise { const config = context.services.agentContext?.config; if (!config) { return input; } return flatMapTextParts(input, async (text) => { if (!text.includes(AT_FILE_INJECTION_TRIGGER)) { return [{ text }]; } const injections = extractInjections( text, AT_FILE_INJECTION_TRIGGER, this.commandName, ); if (injections.length === 0) { return [{ text }]; } const output: PromptPipelineContent = []; let lastIndex = 0; for (const injection of injections) { const prefix = text.substring(lastIndex, injection.startIndex); if (prefix) { output.push({ text: prefix }); } const pathStr = injection.content; try { const fileContentParts = await readPathFromWorkspace(pathStr, config); if (fileContentParts.length === 0) { const uiMessage = `File '@{${pathStr}}' was ignored by .gitignore or .geminiignore and was not included in the prompt.`; context.ui.addItem( { type: MessageType.INFO, text: uiMessage }, Date.now(), ); } output.push(...fileContentParts); } catch (error) { const message = error instanceof Error ? error.message : String(error); const uiMessage = `Failed to inject content for '@{${pathStr}}': ${message}`; // `context.invocation` should always be present at this point. debugLogger.error( `Error while loading custom command (${context.invocation!.name}) ${uiMessage}. Leaving placeholder in prompt.`, ); context.ui.addItem( { type: MessageType.ERROR, text: uiMessage }, Date.now(), ); const placeholder = text.substring( injection.startIndex, injection.endIndex, ); output.push({ text: placeholder }); } lastIndex = injection.endIndex; } const suffix = text.substring(lastIndex); if (suffix) { output.push({ text: suffix }); } return output; }); } } ================================================ FILE: packages/cli/src/services/prompt-processors/injectionParser.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { extractInjections } from './injectionParser.js'; describe('extractInjections', () => { const SHELL_TRIGGER = '!{'; const AT_FILE_TRIGGER = '@{'; describe('Basic Functionality', () => { it('should return an empty array if no trigger is present', () => { const prompt = 'This is a simple prompt without injections.'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([]); }); it('should extract a single, simple injection', () => { const prompt = 'Run this command: !{ls -la}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([ { content: 'ls -la', startIndex: 18, endIndex: 27, }, ]); }); it('should extract multiple injections', () => { const prompt = 'First: !{cmd1}, Second: !{cmd2}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(2); expect(result[0]).toEqual({ content: 'cmd1', startIndex: 7, endIndex: 14, }); expect(result[1]).toEqual({ content: 'cmd2', startIndex: 24, endIndex: 31, }); }); it('should handle different triggers (e.g., @{)', () => { const prompt = 'Read this file: @{path/to/file.txt}'; const result = extractInjections(prompt, AT_FILE_TRIGGER); expect(result).toEqual([ { content: 'path/to/file.txt', startIndex: 16, endIndex: 35, }, ]); }); }); describe('Positioning and Edge Cases', () => { it('should handle injections at the start and end of the prompt', () => { const prompt = '!{start} middle text !{end}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(2); expect(result[0]).toEqual({ content: 'start', startIndex: 0, endIndex: 8, }); expect(result[1]).toEqual({ content: 'end', startIndex: 21, endIndex: 27, }); }); it('should handle adjacent injections', () => { const prompt = '!{A}!{B}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(2); expect(result[0]).toEqual({ content: 'A', startIndex: 0, endIndex: 4 }); expect(result[1]).toEqual({ content: 'B', startIndex: 4, endIndex: 8 }); }); it('should handle empty injections', () => { const prompt = 'Empty: !{}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([ { content: '', startIndex: 7, endIndex: 10, }, ]); }); it('should trim whitespace within the content', () => { const prompt = '!{ \n command with space \t }'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([ { content: 'command with space', startIndex: 0, endIndex: 29, }, ]); }); it('should ignore similar patterns that are not the exact trigger', () => { const prompt = 'Not a trigger: !(cmd) or {cmd} or ! {cmd}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([]); }); it('should ignore extra closing braces before the trigger', () => { const prompt = 'Ignore this } then !{run}'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([ { content: 'run', startIndex: 19, endIndex: 25, }, ]); }); it('should stop parsing at the first balanced closing brace (non-greedy)', () => { // This tests that the parser doesn't greedily consume extra closing braces const prompt = 'Run !{ls -l}} extra braces'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toEqual([ { content: 'ls -l', startIndex: 4, endIndex: 12, }, ]); }); }); describe('Nested Braces (Balanced)', () => { it('should correctly parse content with simple nested braces (e.g., JSON)', () => { const prompt = `Send JSON: !{curl -d '{"key": "value"}'}`; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(1); expect(result[0].content).toBe(`curl -d '{"key": "value"}'`); }); it('should correctly parse content with shell constructs (e.g., awk)', () => { const prompt = `Process text: !{awk '{print $1}' file.txt}`; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(1); expect(result[0].content).toBe(`awk '{print $1}' file.txt`); }); it('should correctly parse multiple levels of nesting', () => { const prompt = `!{level1 {level2 {level3}} suffix}`; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(1); expect(result[0].content).toBe(`level1 {level2 {level3}} suffix`); expect(result[0].endIndex).toBe(prompt.length); }); it('should correctly parse paths containing balanced braces', () => { const prompt = 'Analyze @{path/with/{braces}/file.txt}'; const result = extractInjections(prompt, AT_FILE_TRIGGER); expect(result).toHaveLength(1); expect(result[0].content).toBe('path/with/{braces}/file.txt'); }); it('should correctly handle an injection containing the trigger itself', () => { // This works because the parser counts braces, it doesn't look for the trigger again until the current one is closed. const prompt = '!{echo "The trigger is !{ confusing }"}'; const expectedContent = 'echo "The trigger is !{ confusing }"'; const result = extractInjections(prompt, SHELL_TRIGGER); expect(result).toHaveLength(1); expect(result[0].content).toBe(expectedContent); }); }); describe('Error Handling (Unbalanced/Unclosed)', () => { it('should throw an error for a simple unclosed injection', () => { const prompt = 'This prompt has !{an unclosed trigger'; expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow( /Invalid syntax: Unclosed injection starting at index 16 \('!{'\)/, ); }); it('should throw an error if the prompt ends inside a nested block', () => { const prompt = 'This fails: !{outer {inner'; expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow( /Invalid syntax: Unclosed injection starting at index 12 \('!{'\)/, ); }); it('should include the context name in the error message if provided', () => { const prompt = 'Failing !{command'; const contextName = 'test-command'; expect(() => extractInjections(prompt, SHELL_TRIGGER, contextName), ).toThrow( /Invalid syntax in command 'test-command': Unclosed injection starting at index 8/, ); }); it('should throw if content contains unbalanced braces (e.g., missing closing)', () => { // This is functionally the same as an unclosed injection from the parser's perspective. const prompt = 'Analyze @{path/with/braces{example.txt}'; expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow( /Invalid syntax: Unclosed injection starting at index 8 \('@{'\)/, ); }); it('should clearly state that unbalanced braces in content are not supported in the error', () => { const prompt = 'Analyze @{path/with/braces{example.txt}'; expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow( /Paths or commands with unbalanced braces are not supported directly/, ); }); }); }); ================================================ FILE: packages/cli/src/services/prompt-processors/injectionParser.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Represents a single detected injection site in a prompt string. */ export interface Injection { /** The content extracted from within the braces (e.g., the command or path), trimmed. */ content: string; /** The starting index of the injection (inclusive, points to the start of the trigger). */ startIndex: number; /** The ending index of the injection (exclusive, points after the closing '}'). */ endIndex: number; } /** * Iteratively parses a prompt string to extract injections (e.g., !{...} or @{...}), * correctly handling nested braces within the content. * * This parser relies on simple brace counting and does not support escaping. * * @param prompt The prompt string to parse. * @param trigger The opening trigger sequence (e.g., '!{', '@{'). * @param contextName Optional context name (e.g., command name) for error messages. * @returns An array of extracted Injection objects. * @throws Error if an unclosed injection is found. */ export function extractInjections( prompt: string, trigger: string, contextName?: string, ): Injection[] { const injections: Injection[] = []; let index = 0; while (index < prompt.length) { const startIndex = prompt.indexOf(trigger, index); if (startIndex === -1) { break; } let currentIndex = startIndex + trigger.length; let braceCount = 1; let foundEnd = false; while (currentIndex < prompt.length) { const char = prompt[currentIndex]; if (char === '{') { braceCount++; } else if (char === '}') { braceCount--; if (braceCount === 0) { const injectionContent = prompt.substring( startIndex + trigger.length, currentIndex, ); const endIndex = currentIndex + 1; injections.push({ content: injectionContent.trim(), startIndex, endIndex, }); index = endIndex; foundEnd = true; break; } } currentIndex++; } // Check if the inner loop finished without finding the closing brace. if (!foundEnd) { const contextInfo = contextName ? ` in command '${contextName}'` : ''; // Enforce strict parsing (Comment 1) and clarify limitations (Comment 2). throw new Error( `Invalid syntax${contextInfo}: Unclosed injection starting at index ${startIndex} ('${trigger}'). Ensure braces are balanced. Paths or commands with unbalanced braces are not supported directly.`, ); } } return injections; } ================================================ FILE: packages/cli/src/services/prompt-processors/shellProcessor.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from '../../ui/commands/types.js'; import type { Config } from '@google/gemini-cli-core'; import { ApprovalMode, getShellConfiguration, PolicyDecision, NoopSandboxManager, } from '@google/gemini-cli-core'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; import type { PromptPipelineContent } from './types.js'; // Helper function to determine the expected escaped string based on the current OS, // mirroring the logic in the actual `escapeShellArg` implementation. function getExpectedEscapedArgForPlatform(arg: string): string { const { shell } = getShellConfiguration(); switch (shell) { case 'powershell': return `'${arg.replace(/'/g, "''")}'`; case 'cmd': return `"${arg.replace(/"/g, '""')}"`; case 'bash': default: return quote([arg]); } } // Helper to create PromptPipelineContent function createPromptPipelineContent(text: string): PromptPipelineContent { return [createPartFromText(text)]; } const mockCheckCommandPermissions = vi.hoisted(() => vi.fn()); const mockShellExecute = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, checkCommandPermissions: mockCheckCommandPermissions, ShellExecutionService: { execute: mockShellExecute, }, }; }); const SUCCESS_RESULT = { output: 'default shell output', exitCode: 0, error: null, aborted: false, signal: null, }; describe('ShellProcessor', () => { let context: CommandContext; let mockConfig: Partial; let mockPolicyEngineCheck: Mock; beforeEach(() => { vi.clearAllMocks(); mockPolicyEngineCheck = vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW, }); mockConfig = { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), getShellExecutionConfig: vi.fn().mockReturnValue({ sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }, }), getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), get config() { return this as unknown as Config; }, }; context = createMockCommandContext({ invocation: { raw: '/cmd default args', name: 'cmd', args: 'default args', }, services: { agentContext: mockConfig as Config, }, session: { sessionShellAllowlist: new Set(), }, }); mockShellExecute.mockReturnValue({ result: Promise.resolve(SUCCESS_RESULT), }); mockCheckCommandPermissions.mockReturnValue({ allAllowed: true, disallowedCommands: [], }); }); it('should throw an error if config is missing', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); await expect( processor.process(prompt, contextWithoutConfig), ).rejects.toThrow(/Security configuration not loaded/); }); it('should not change the prompt if no shell injections are present', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'This is a simple prompt with no injections.', ); const result = await processor.process(prompt, context); expect(result).toEqual(prompt); expect(mockShellExecute).not.toHaveBeenCalled(); }); it('should process a single valid shell injection if allowed', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'The current status is: !{git status}', ); mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }), }); const result = await processor.process(prompt, context); expect(mockPolicyEngineCheck).toHaveBeenCalledWith( { name: 'run_shell_command', args: { command: 'git status' }, }, undefined, ); expect(mockShellExecute).toHaveBeenCalledWith( 'git status', expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); expect(result).toEqual([{ text: 'The current status is: On branch main' }]); }); it('should process multiple valid shell injections if all are allowed', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( '!{git status} in !{pwd}', ); mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); mockShellExecute .mockReturnValueOnce({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main', }), }) .mockReturnValueOnce({ result: Promise.resolve({ ...SUCCESS_RESULT, output: '/usr/home' }), }); const result = await processor.process(prompt, context); expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(2); expect(mockShellExecute).toHaveBeenCalledTimes(2); expect(result).toEqual([{ text: 'On branch main in /usr/home' }]); }); it('should throw ConfirmationRequiredError if a command is not allowed in default mode', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ASK_USER, }); await expect(processor.process(prompt, context)).rejects.toThrow( ConfirmationRequiredError, ); }); it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); // In YOLO mode, PolicyEngine returns ALLOW mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); // Override the approval mode for this test (though PolicyEngine mock handles the decision) (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), }); const result = await processor.process(prompt, context); // It should proceed with execution expect(mockShellExecute).toHaveBeenCalledWith( 'rm -rf /', expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]); }); it('should still throw an error for a hard-denied command even in YOLO mode', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something forbidden: !{reboot}', ); mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.DENY, }); // Set approval mode to YOLO (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); await expect(processor.process(prompt, context)).rejects.toThrow( /Blocked command: "reboot". Reason: Blocked by policy/, ); // Ensure it never tried to execute expect(mockShellExecute).not.toHaveBeenCalled(); }); it('should throw ConfirmationRequiredError with the correct command', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ASK_USER, }); try { await processor.process(prompt, context); // Fail if it doesn't throw expect(true).toBe(false); } catch (e) { expect(e).toBeInstanceOf(ConfirmationRequiredError); if (e instanceof ConfirmationRequiredError) { expect(e.commandsToConfirm).toEqual(['rm -rf /']); } } expect(mockShellExecute).not.toHaveBeenCalled(); }); it('should throw ConfirmationRequiredError with multiple commands if multiple are disallowed', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( '!{cmd1} and !{cmd2}', ); mockPolicyEngineCheck.mockImplementation(async (toolCall) => { const cmd = toolCall.args.command; if (cmd === 'cmd1' || cmd === 'cmd2') { return { decision: PolicyDecision.ASK_USER }; } return { decision: PolicyDecision.ALLOW }; }); try { await processor.process(prompt, context); // Fail if it doesn't throw expect(true).toBe(false); } catch (e) { expect(e).toBeInstanceOf(ConfirmationRequiredError); if (e instanceof ConfirmationRequiredError) { expect(e.commandsToConfirm).toEqual(['cmd1', 'cmd2']); } } }); it('should not execute any commands if at least one requires confirmation', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'First: !{echo "hello"}, Second: !{rm -rf /}', ); mockPolicyEngineCheck.mockImplementation(async (toolCall) => { const cmd = toolCall.args.command; if (cmd.includes('rm')) { return { decision: PolicyDecision.ASK_USER }; } return { decision: PolicyDecision.ALLOW }; }); await expect(processor.process(prompt, context)).rejects.toThrow( ConfirmationRequiredError, ); // Ensure no commands were executed because the pipeline was halted. expect(mockShellExecute).not.toHaveBeenCalled(); }); it('should only request confirmation for disallowed commands in a mixed prompt', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}', ); mockPolicyEngineCheck.mockImplementation(async (toolCall) => { const cmd = toolCall.args.command; if (cmd.includes('rm')) { return { decision: PolicyDecision.ASK_USER }; } return { decision: PolicyDecision.ALLOW }; }); try { await processor.process(prompt, context); expect.fail('Should have thrown ConfirmationRequiredError'); } catch (e) { expect(e).toBeInstanceOf(ConfirmationRequiredError); if (e instanceof ConfirmationRequiredError) { expect(e.commandsToConfirm).toEqual(['rm -rf /']); } } }); it('should execute all commands if they are on the session allowlist', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Run !{cmd1} and !{cmd2}', ); // Add commands to the session allowlist (conceptually, in this test we just mock the engine allowing them) context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']); // checkCommandPermissions should now pass for these mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); mockShellExecute .mockReturnValueOnce({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output1' }), }) .mockReturnValueOnce({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output2' }), }); const result = await processor.process(prompt, context); expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); expect(mockShellExecute).toHaveBeenCalledTimes(2); expect(result).toEqual([{ text: 'Run output1 and output2' }]); }); it('should support the full confirmation flow (Ask -> Approve -> Retry)', async () => { // 1. Initial State: Command NOT allowed const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{echo "once"}'); // Policy Engine says ASK_USER mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ASK_USER, }); // 2. First Attempt: processing should fail with ConfirmationRequiredError try { await processor.process(prompt, context); expect.fail('Should have thrown ConfirmationRequiredError'); } catch (e) { expect(e).toBeInstanceOf(ConfirmationRequiredError); expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(1); } // 3. User Approves: Add to session allowlist (simulating UI action) context.session.sessionShellAllowlist.add('echo "once"'); // 4. Retry: calling process() again with the same context // Reset mocks to ensure we track new calls cleanly mockPolicyEngineCheck.mockClear(); // Mock successful execution mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'once' }), }); const result = await processor.process(prompt, context); // 5. Verify Success AND Policy Engine Bypass expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); expect(mockShellExecute).toHaveBeenCalledWith( 'echo "once"', expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); expect(result).toEqual([{ text: 'once' }]); }); it('should trim whitespace from the command inside the injection before interpolation', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Files: !{ ls {{args}} -l }', ); const rawArgs = context.invocation!.args; const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedCommand = `ls ${expectedEscapedArgs} -l`; mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }), }); await processor.process(prompt, context); expect(mockPolicyEngineCheck).toHaveBeenCalledWith( { name: 'run_shell_command', args: { command: expectedCommand } }, undefined, ); expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); }); it('should handle an empty command inside the injection gracefully (skips execution)', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('This is weird: !{}'); const result = await processor.process(prompt, context); expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); expect(mockShellExecute).not.toHaveBeenCalled(); // It replaces !{} with an empty string. expect(result).toEqual([{ text: 'This is weird: ' }]); }); describe('Error Reporting', () => { it('should append exit code and command name on failure', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{cmd}'); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'some error output', stderr: '', exitCode: 1, }), }); const result = await processor.process(prompt, context); expect(result).toEqual([ { text: "some error output\n[Shell command 'cmd' exited with code 1]", }, ]); }); it('should append signal info and command name if terminated by signal', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{cmd}'); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output', stderr: '', exitCode: null, signal: 'SIGTERM', }), }); const result = await processor.process(prompt, context); expect(result).toEqual([ { text: "output\n[Shell command 'cmd' terminated by signal SIGTERM]", }, ]); }); it('should throw a detailed error if the shell fails to spawn', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{bad-command}'); const spawnError = new Error('spawn EACCES'); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '', stderr: '', exitCode: null, error: spawnError, aborted: false, }), }); await expect(processor.process(prompt, context)).rejects.toThrow( "Failed to start shell command in 'test-command': spawn EACCES. Command: bad-command", ); }); it('should report abort status with command name if aborted', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( '!{long-running-command}', ); const spawnError = new Error('Aborted'); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'partial output', stderr: '', exitCode: null, error: spawnError, aborted: true, // Key difference }), }); const result = await processor.process(prompt, context); expect(result).toEqual([ { text: "partial output\n[Shell command 'long-running-command' aborted]", }, ]); }); }); describe('Context-Aware Argument Interpolation ({{args}})', () => { const rawArgs = 'user input'; beforeEach(() => { // Update context for these tests to use specific arguments context.invocation!.args = rawArgs; }); it('should perform raw replacement if no shell injections are present (optimization path)', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'The user said: {{args}}', ); const result = await processor.process(prompt, context); expect(result).toEqual([{ text: `The user said: ${rawArgs}` }]); expect(mockShellExecute).not.toHaveBeenCalled(); }); it('should perform raw replacement outside !{} blocks', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Outside: {{args}}. Inside: !{echo "hello"}', ); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'hello' }), }); const result = await processor.process(prompt, context); expect(result).toEqual([{ text: `Outside: ${rawArgs}. Inside: hello` }]); }); it('should perform escaped replacement inside !{} blocks', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'Command: !{grep {{args}} file.txt}', ); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'match found' }), }); const result = await processor.process(prompt, context); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedCommand = `grep ${expectedEscapedArgs} file.txt`; expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); expect(result).toEqual([{ text: 'Command: match found' }]); }); it('should handle both raw (outside) and escaped (inside) injection simultaneously', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( 'User "({{args}})" requested search: !{search {{args}}}', ); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'results' }), }); const result = await processor.process(prompt, context); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedCommand = `search ${expectedEscapedArgs}`; expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); expect(result).toEqual([ { text: `User "(${rawArgs})" requested search: results` }, ]); }); it('should perform security checks on the final, resolved (escaped) command', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{rm {{args}}}'); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.ASK_USER, }); await expect(processor.process(prompt, context)).rejects.toThrow( ConfirmationRequiredError, ); expect(mockPolicyEngineCheck).toHaveBeenCalledWith( { name: 'run_shell_command', args: { command: expectedResolvedCommand }, }, undefined, ); }); it('should report the resolved command if a hard denial occurs', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent('!{rm {{args}}}'); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; mockPolicyEngineCheck.mockResolvedValue({ decision: PolicyDecision.DENY, }); await expect(processor.process(prompt, context)).rejects.toThrow( `Blocked command: "${expectedResolvedCommand}". Reason: Blocked by policy.`, ); }); }); describe('Real-World Escaping Scenarios', () => { it('should correctly handle multiline arguments', async () => { const processor = new ShellProcessor('test-command'); const multilineArgs = 'first line\nsecond line'; context.invocation!.args = multilineArgs; const prompt: PromptPipelineContent = createPromptPipelineContent( 'Commit message: !{git commit -m {{args}}}', ); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(multilineArgs); const expectedCommand = `git commit -m ${expectedEscapedArgs}`; await processor.process(prompt, context); expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); }); it.each([ { name: 'spaces', input: 'file with spaces.txt' }, { name: 'double quotes', input: 'a "quoted" string' }, { name: 'single quotes', input: "it's a string" }, { name: 'command substitution (backticks)', input: '`reboot`' }, { name: 'command substitution (dollar)', input: '$(reboot)' }, { name: 'variable expansion', input: '$HOME' }, { name: 'command chaining (semicolon)', input: 'a; reboot' }, { name: 'command chaining (ampersand)', input: 'a && reboot' }, ])('should safely escape args containing $name', async ({ input }) => { const processor = new ShellProcessor('test-command'); context.invocation!.args = input; const prompt: PromptPipelineContent = createPromptPipelineContent('!{echo {{args}}}'); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(input); const expectedCommand = `echo ${expectedEscapedArgs}`; await processor.process(prompt, context); expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, expect.any(String), expect.any(Function), expect.any(Object), false, expect.any(Object), ); }); }); }); ================================================ FILE: packages/cli/src/services/prompt-processors/shellProcessor.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { escapeShellArg, getShellConfiguration, ShellExecutionService, flatMapTextParts, PolicyDecision, } from '@google/gemini-cli-core'; import type { CommandContext } from '../../ui/commands/types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; import { SHELL_INJECTION_TRIGGER, SHORTHAND_ARGS_PLACEHOLDER, } from './types.js'; import { extractInjections, type Injection } from './injectionParser.js'; import { themeManager } from '../../ui/themes/theme-manager.js'; export class ConfirmationRequiredError extends Error { constructor( message: string, public commandsToConfirm: string[], ) { super(message); this.name = 'ConfirmationRequiredError'; } } /** * Represents a single detected shell injection site in the prompt, * after resolution of arguments. Extends the base Injection interface. */ interface ResolvedShellInjection extends Injection { /** The command after {{args}} has been escaped and substituted. */ resolvedCommand?: string; } /** * Handles prompt interpolation, including shell command execution (`!{...}`) * and context-aware argument injection (`{{args}}`). * * This processor ensures that: * 1. `{{args}}` outside `!{...}` are replaced with raw input. * 2. `{{args}}` inside `!{...}` are replaced with shell-escaped input. * 3. Shell commands are executed securely after argument substitution. * 4. Parsing correctly handles nested braces. */ export class ShellProcessor implements IPromptProcessor { constructor(private readonly commandName: string) {} async process( prompt: PromptPipelineContent, context: CommandContext, ): Promise { return flatMapTextParts(prompt, (text) => this.processString(text, context), ); } private async processString( prompt: string, context: CommandContext, ): Promise { const userArgsRaw = context.invocation?.args || ''; if (!prompt.includes(SHELL_INJECTION_TRIGGER)) { return [ { text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) }, ]; } const config = context.services.agentContext?.config; if (!config) { throw new Error( `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, ); } const injections = extractInjections( prompt, SHELL_INJECTION_TRIGGER, this.commandName, ); // If extractInjections found no closed blocks (and didn't throw), treat as raw. if (injections.length === 0) { return [ { text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) }, ]; } const { shell } = getShellConfiguration(); const userArgsEscaped = escapeShellArg(userArgsRaw, shell); const resolvedInjections: ResolvedShellInjection[] = injections.map( (injection) => { const command = injection.content; if (command === '') { return { ...injection, resolvedCommand: undefined }; } const resolvedCommand = command.replaceAll( SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped, ); return { ...injection, resolvedCommand }; }, ); const commandsToConfirm = new Set(); for (const injection of resolvedInjections) { const command = injection.resolvedCommand; if (!command) continue; if (context.session.sessionShellAllowlist?.has(command)) { continue; } // Security check on the final, escaped command string. const { decision } = await config.getPolicyEngine().check( { name: 'run_shell_command', args: { command }, }, undefined, ); if (decision === PolicyDecision.DENY) { throw new Error( `${this.commandName} cannot be run. Blocked command: "${command}". Reason: Blocked by policy.`, ); } else if (decision === PolicyDecision.ASK_USER) { commandsToConfirm.add(command); } } // Handle confirmation requirements. if (commandsToConfirm.size > 0) { throw new ConfirmationRequiredError( 'Shell command confirmation required', Array.from(commandsToConfirm), ); } let processedPrompt = ''; let lastIndex = 0; for (const injection of resolvedInjections) { // Append the text segment BEFORE the injection, substituting {{args}} with RAW input. const segment = prompt.substring(lastIndex, injection.startIndex); processedPrompt += segment.replaceAll( SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw, ); // Execute the resolved command (which already has ESCAPED input). if (injection.resolvedCommand) { const activeTheme = themeManager.getActiveTheme(); const shellExecutionConfig = { ...config.getShellExecutionConfig(), defaultFg: activeTheme.colors.Foreground, defaultBg: activeTheme.colors.Background, }; const { result } = await ShellExecutionService.execute( injection.resolvedCommand, config.getTargetDir(), () => {}, new AbortController().signal, config.getEnableInteractiveShell(), shellExecutionConfig, ); const executionResult = await result; // Handle Spawn Errors if (executionResult.error && !executionResult.aborted) { throw new Error( `Failed to start shell command in '${this.commandName}': ${executionResult.error.message}. Command: ${injection.resolvedCommand}`, ); } // Append the output, making stderr explicit for the model. processedPrompt += executionResult.output; // Append a status message if the command did not succeed. if (executionResult.aborted) { processedPrompt += `\n[Shell command '${injection.resolvedCommand}' aborted]`; } else if ( executionResult.exitCode !== 0 && executionResult.exitCode !== null ) { processedPrompt += `\n[Shell command '${injection.resolvedCommand}' exited with code ${executionResult.exitCode}]`; } else if (executionResult.signal !== null) { processedPrompt += `\n[Shell command '${injection.resolvedCommand}' terminated by signal ${executionResult.signal}]`; } } lastIndex = injection.endIndex; } // Append the remaining text AFTER the last injection, substituting {{args}} with RAW input. const finalSegment = prompt.substring(lastIndex); processedPrompt += finalSegment.replaceAll( SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw, ); return [{ text: processedPrompt }]; } } ================================================ FILE: packages/cli/src/services/prompt-processors/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { CommandContext } from '../../ui/commands/types.js'; import type { PartUnion } from '@google/genai'; /** * Defines the input/output type for prompt processors. */ export type PromptPipelineContent = PartUnion[]; /** * Defines the interface for a prompt processor, a module that can transform * a prompt string before it is sent to the model. Processors are chained * together to create a processing pipeline. */ export interface IPromptProcessor { /** * Processes a prompt input (which may contain text and multi-modal parts), * applying a specific transformation as part of a pipeline. * * @param prompt The current state of the prompt string. This may have been * modified by previous processors in the pipeline. * @param context The full command context, providing access to invocation * details (like `context.invocation.raw` and `context.invocation.args`), * application services, and UI handlers. * @returns A promise that resolves to the transformed prompt string, which * will be passed to the next processor or, if it's the last one, sent to the model. */ process( prompt: PromptPipelineContent, context: CommandContext, ): Promise; } /** * The placeholder string for shorthand argument injection in custom commands. * When used outside of !{...}, arguments are injected raw. * When used inside !{...}, arguments are shell-escaped. */ export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}'; /** * The trigger string for shell command injection in custom commands. */ export const SHELL_INJECTION_TRIGGER = '!{'; /** * The trigger string for at file injection in custom commands. */ export const AT_FILE_INJECTION_TRIGGER = '@{'; ================================================ FILE: packages/cli/src/services/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { SlashCommand } from '../ui/commands/types.js'; /** * Defines the contract for any class that can load and provide slash commands. * This allows the CommandService to be extended with new command sources * (e.g., file-based, remote APIs) without modification. * * Loaders should receive any necessary dependencies (like Config) via their * constructor. */ export interface ICommandLoader { /** * Discovers and returns a list of slash commands from the loader's source. * @param signal An AbortSignal to allow cancellation. * @returns A promise that resolves to an array of SlashCommand objects. */ loadCommands(signal: AbortSignal): Promise; } export interface CommandConflict { name: string; losers: Array<{ command: SlashCommand; renamedTo: string; reason: SlashCommand; }>; } ================================================ FILE: packages/cli/src/test-utils/AppRig.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, afterEach, expect } from 'vitest'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { debugLogger } from '@google/gemini-cli-core'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('AppRig', () => { let rig: AppRig | undefined; afterEach(async () => { await rig?.unmount(); }); it('should handle deterministic tool turns with breakpoints', async () => { const fakeResponsesPath = path.join( __dirname, 'fixtures', 'steering.responses', ); rig = new AppRig({ fakeResponsesPath, configOverrides: { modelSteering: true }, }); await rig.initialize(); await rig.render(); await rig.waitForIdle(); // Set breakpoints on the canonical tool names rig.setBreakpoint('list_directory'); rig.setBreakpoint('read_file'); // Start a task debugLogger.log('[Test] Sending message: Start long task'); await rig.sendMessage('Start long task'); // Wait for the first breakpoint (list_directory) const pending1 = await rig.waitForPendingConfirmation('list_directory'); expect(pending1.toolName).toBe('list_directory'); // Injected a hint await rig.addUserHint('focus on .txt'); // Resolve and wait for the NEXT breakpoint (read_file) // resolveTool will automatically remove the breakpoint policy for list_directory await rig.resolveTool('list_directory'); const pending2 = await rig.waitForPendingConfirmation('read_file'); expect(pending2.toolName).toBe('read_file'); // Resolve and finish. Also removes read_file breakpoint. await rig.resolveTool('read_file'); await rig.waitForOutput('Task complete.', 100000); }); it('should render the app and handle a simple message', async () => { const fakeResponsesPath = path.join( __dirname, 'fixtures', 'simple.responses', ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); await rig.render(); // Wait for initial render await rig.waitForIdle(); // Type a message await rig.type('Hello'); await rig.pressEnter(); // Wait for model response await rig.waitForOutput('Hello! How can I help you today?'); }); }); ================================================ FILE: packages/cli/src/test-utils/AppRig.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; import { act } from 'react'; import stripAnsi from 'strip-ansi'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; import { renderWithProviders, type RenderInstance } from './render.js'; import { makeFakeConfig, type Config, type ConfigParameters, ExtensionLoader, AuthType, ApprovalMode, createPolicyEngineConfig, PolicyDecision, ToolConfirmationOutcome, MessageBusType, type ToolCallsUpdateMessage, coreEvents, ideContextStore, createContentGenerator, IdeClient, debugLogger, CoreToolCallStatus, IntegrityDataStatus, } from '@google/gemini-cli-core'; import { type MockShellCommand, MockShellExecutionService, } from './MockShellExecutionService.js'; import { createMockSettings } from './settings.js'; import { type LoadedSettings, resetSettingsCacheForTesting, } from '../config/settings.js'; import { AuthState, StreamingState } from '../ui/types.js'; import { randomUUID } from 'node:crypto'; import type { TrackedCancelledToolCall, TrackedCompletedToolCall, TrackedToolCall, } from '../ui/hooks/useToolScheduler.js'; // Global state observer for React-based signals const sessionStateMap = new Map(); const activeRigs = new Map(); // Mock StreamingContext to report state changes back to the observer vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => { const original = await importOriginal(); const { useConfig } = await import('../ui/contexts/ConfigContext.js'); const React = await import('react'); return { ...original, useStreamingContext: () => { const state = original.useStreamingContext(); const config = useConfig(); const sessionId = config.getSessionId(); React.useEffect(() => { sessionStateMap.set(sessionId, state); // If we see activity, we are no longer "awaiting" the start of a response if (state !== StreamingState.Idle) { const rig = activeRigs.get(sessionId); if (rig) { rig.awaitingResponse = false; } } }, [sessionId, state]); return state; }, }; }); // Mock core functions globally for tests using AppRig. vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); const { MockShellExecutionService: MockService } = await import( './MockShellExecutionService.js' ); // Register the real execution logic so MockShellExecutionService can fall back to it MockService.setOriginalImplementation(original.ShellExecutionService.execute); return { ...original, ShellExecutionService: MockService, }; }); // Mock useAuthCommand to bypass authentication flows in tests vi.mock('../ui/auth/useAuth.js', () => ({ useAuthCommand: () => ({ authState: AuthState.Authenticated, setAuthState: vi.fn(), authError: null, onAuthError: vi.fn(), apiKeyDefaultValue: 'test-api-key', reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), accountSuspensionInfo: null, setAccountSuspensionInfo: vi.fn(), }), validateAuthMethodWithSettings: () => null, })); // A minimal mock ExtensionManager to satisfy AppContainer's forceful cast class MockExtensionManager extends ExtensionLoader { getExtensions = vi.fn().mockReturnValue([]); setRequestConsent = vi.fn(); setRequestSetting = vi.fn(); integrityManager = { verifyExtensionIntegrity: vi .fn() .mockResolvedValue(IntegrityDataStatus.VERIFIED), storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), }; } // Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode. vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => { const React = await import('react'); const { Text } = await import('ink'); return { GeminiSpinner: () => React.createElement(Text, null, '...'), GeminiRespondingSpinner: ({ nonRespondingDisplay, }: { nonRespondingDisplay: string; }) => React.createElement(Text, null, nonRespondingDisplay || '...'), }; }); export interface AppRigOptions { fakeResponsesPath?: string; terminalWidth?: number; terminalHeight?: number; configOverrides?: Partial; } export interface PendingConfirmation { toolName: string; toolDisplayName?: string; correlationId: string; } export class AppRig { private renderResult: RenderInstance | undefined; private config: Config | undefined; private settings: LoadedSettings | undefined; private testDir: string; private sessionId: string; private pendingConfirmations = new Map(); private breakpointTools = new Set(); private lastAwaitedConfirmation: PendingConfirmation | undefined; /** * True if a message was just sent but React hasn't yet reported a non-idle state. */ awaitingResponse = false; constructor(private options: AppRigOptions = {}) { const uniqueId = randomUUID(); this.testDir = fs.mkdtempSync( path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`), ); this.sessionId = `test-session-${uniqueId}`; activeRigs.set(this.sessionId, this); } async initialize() { this.setupEnvironment(); resetSettingsCacheForTesting(); this.settings = this.createRigSettings(); const approvalMode = this.options.configOverrides?.approvalMode ?? ApprovalMode.DEFAULT; const policyEngineConfig = await createPolicyEngineConfig( this.settings.merged, approvalMode, ); const configParams: ConfigParameters = { sessionId: this.sessionId, targetDir: this.testDir, cwd: this.testDir, debugMode: false, model: 'test-model', fakeResponses: this.options.fakeResponsesPath, interactive: true, approvalMode, policyEngineConfig, enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); if (this.options.fakeResponsesPath) { this.stubRefreshAuth(); } this.setupMessageBusListeners(); await act(async () => { await this.config!.initialize(); // Since we mocked useAuthCommand, we must manually trigger the first // refreshAuth to ensure contentGenerator is initialized. await this.config!.refreshAuth(AuthType.USE_GEMINI); }); } private setupEnvironment() { // Stub environment variables to avoid interference from developer's machine vi.stubEnv('GEMINI_CLI_HOME', this.testDir); if (this.options.fakeResponsesPath) { vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); MockShellExecutionService.setPassthrough(false); } else { if (!process.env['GEMINI_API_KEY']) { throw new Error( 'GEMINI_API_KEY must be set in the environment for live model tests.', ); } // For live tests, we allow falling through to the real shell service if no mock matches MockShellExecutionService.setPassthrough(true); } vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI); } private createRigSettings(): LoadedSettings { return createMockSettings({ user: { path: path.join(this.testDir, '.gemini', 'user_settings.json'), settings: { security: { auth: { selectedType: AuthType.USE_GEMINI, useExternal: true, }, folderTrust: { enabled: true, }, }, ide: { enabled: false, hasSeenNudge: true, }, }, originalSettings: {}, }, merged: { security: { auth: { selectedType: AuthType.USE_GEMINI, useExternal: true, }, folderTrust: { enabled: true, }, }, ide: { enabled: false, hasSeenNudge: true, }, ui: { useAlternateBuffer: false, }, }, }); } private stubRefreshAuth() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; gcConfig.contentGenerator = await createContentGenerator( newContentGeneratorConfig, this.config!, gcConfig.getSessionId(), ); gcConfig.contentGeneratorConfig = newContentGeneratorConfig; // Initialize BaseLlmClient now that the ContentGenerator is available const { BaseLlmClient } = await import('@google/gemini-cli-core'); gcConfig.baseLlmClient = new BaseLlmClient( gcConfig.contentGenerator, this.config!, ); }; } private toolCalls: TrackedToolCall[] = []; private setupMessageBusListeners() { if (!this.config) return; const messageBus = this.config.getMessageBus(); messageBus.subscribe( MessageBusType.TOOL_CALLS_UPDATE, (message: ToolCallsUpdateMessage) => { this.toolCalls = message.toolCalls; for (const call of message.toolCalls) { if (call.status === 'awaiting_approval' && call.correlationId) { const details = call.confirmationDetails; const title = 'title' in details ? details.title : ''; const toolDisplayName = call.tool?.displayName || title.replace(/^Confirm:\s*/, ''); if (!this.pendingConfirmations.has(call.correlationId)) { this.pendingConfirmations.set(call.correlationId, { toolName: call.request.name, toolDisplayName, correlationId: call.correlationId, }); } } else if (call.status !== 'awaiting_approval') { for (const [ correlationId, pending, ] of this.pendingConfirmations.entries()) { if (pending.toolName === call.request.name) { this.pendingConfirmations.delete(correlationId); break; } } } } }, ); } /** * Returns true if the agent is currently busy (responding or executing tools). */ isBusy(): boolean { if (this.awaitingResponse) { return true; } const reactState = sessionStateMap.get(this.sessionId); // If we have a React-based state, use it as the definitive signal. // 'responding' and 'waiting-for-confirmation' both count as busy for the overall task. if (reactState !== undefined) { return reactState !== StreamingState.Idle; } // Fallback to tool tracking if React hasn't reported yet const isAnyToolActive = this.toolCalls.some((tc) => { if ( tc.status === CoreToolCallStatus.Executing || tc.status === CoreToolCallStatus.Scheduled || tc.status === CoreToolCallStatus.Validating ) { return true; } if ( tc.status === CoreToolCallStatus.Success || tc.status === CoreToolCallStatus.Error || tc.status === CoreToolCallStatus.Cancelled ) { return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall) .responseSubmittedToGemini; } return false; }); const isAwaitingConfirmation = this.toolCalls.some( (tc) => tc.status === CoreToolCallStatus.AwaitingApproval, ); return isAnyToolActive || isAwaitingConfirmation; } async render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); await act(async () => { this.renderResult = await renderWithProviders( , { config: this.config!, settings: this.settings!, width: this.options.terminalWidth ?? 120, uiState: { terminalHeight: this.options.terminalHeight ?? 40, }, }, ); }); } setMockCommands(commands: MockShellCommand[]) { MockShellExecutionService.setMockCommands(commands); } setToolPolicy( toolName: string | undefined, decision: PolicyDecision, priority = 10, ) { if (!this.config) throw new Error('AppRig not initialized'); this.config.getPolicyEngine().addRule({ toolName, decision, priority, source: 'AppRig Override', }); } setBreakpoint(toolName: string | string[] | undefined) { if (Array.isArray(toolName)) { for (const name of toolName) { this.setBreakpoint(name); } } else { // Use undefined toolName to create a global rule if '*' is provided const actualToolName = toolName === '*' ? undefined : toolName; this.setToolPolicy(actualToolName, PolicyDecision.ASK_USER, 100); this.breakpointTools.add(toolName); } } removeToolPolicy(toolName?: string, source = 'AppRig Override') { if (!this.config) throw new Error('AppRig not initialized'); // Map '*' back to undefined for policy removal const actualToolName = toolName === '*' ? undefined : toolName; this.config .getPolicyEngine() .removeRulesForTool(actualToolName as string, source); this.breakpointTools.delete(toolName); } getTestDir(): string { return this.testDir; } getPendingConfirmations() { return Array.from(this.pendingConfirmations.values()); } private async waitUntil( predicate: () => boolean | Promise, options: { timeout?: number; interval?: number; message?: string } = {}, ) { const { timeout = 30000, interval = 100, message = 'Condition timed out', } = options; const start = Date.now(); while (true) { if (await predicate()) return; if (Date.now() - start > timeout) { throw new Error(message); } await act(async () => { await new Promise((resolve) => setTimeout(resolve, interval)); }); } } async waitForPendingConfirmation( toolNameOrDisplayName?: string | RegExp | string[], timeout = 30000, ): Promise { const matches = (p: PendingConfirmation) => { if (!toolNameOrDisplayName) return true; if (typeof toolNameOrDisplayName === 'string') { return ( p.toolName === toolNameOrDisplayName || p.toolDisplayName === toolNameOrDisplayName ); } if (Array.isArray(toolNameOrDisplayName)) { return ( toolNameOrDisplayName.includes(p.toolName) || toolNameOrDisplayName.includes(p.toolDisplayName || '') ); } return ( toolNameOrDisplayName.test(p.toolName) || toolNameOrDisplayName.test(p.toolDisplayName || '') ); }; let matched: PendingConfirmation | undefined; await this.waitUntil( () => { matched = this.getPendingConfirmations().find(matches); return !!matched; }, { timeout, message: `Timed out waiting for pending confirmation: ${toolNameOrDisplayName || 'any'}. Current pending: ${this.getPendingConfirmations() .map((p) => p.toolName) .join(', ')}`, }, ); this.lastAwaitedConfirmation = matched; return matched!; } /** * Waits for either a tool confirmation request OR for the agent to go idle. */ async waitForNextEvent( timeout = 60000, ): Promise< | { type: 'confirmation'; confirmation: PendingConfirmation } | { type: 'idle' } > { let confirmation: PendingConfirmation | undefined; let isIdle = false; await this.waitUntil( async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); confirmation = this.getPendingConfirmations()[0]; // Now that we have a code-powered signal, this should be perfectly deterministic. isIdle = !this.isBusy(); return !!confirmation || isIdle; }, { timeout, message: 'Timed out waiting for next event (confirmation or idle).', }, ); if (confirmation) { this.lastAwaitedConfirmation = confirmation; return { type: 'confirmation', confirmation }; } // Ensure all renders are flushed before returning 'idle' await this.renderResult?.waitUntilReady(); return { type: 'idle' }; } async resolveTool( toolNameOrDisplayName: string | RegExp | PendingConfirmation, outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, ): Promise { if (!this.config) throw new Error('AppRig not initialized'); const messageBus = this.config.getMessageBus(); let pending: PendingConfirmation; if ( typeof toolNameOrDisplayName === 'object' && 'correlationId' in toolNameOrDisplayName ) { pending = toolNameOrDisplayName; } else { pending = await this.waitForPendingConfirmation(toolNameOrDisplayName); } await act(async () => { this.pendingConfirmations.delete(pending.correlationId); if (this.breakpointTools.has(pending.toolName)) { this.removeToolPolicy(pending.toolName); } // eslint-disable-next-line @typescript-eslint/no-floating-promises messageBus.publish({ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, correlationId: pending.correlationId, confirmed: outcome !== ToolConfirmationOutcome.Cancel, outcome, }); }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); } async resolveAwaitedTool( outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, ): Promise { if (!this.lastAwaitedConfirmation) { throw new Error('No tool has been awaited yet'); } await this.resolveTool(this.lastAwaitedConfirmation, outcome); this.lastAwaitedConfirmation = undefined; } async addUserHint(hint: string) { if (!this.config) throw new Error('AppRig not initialized'); await act(async () => { this.config!.injectionService.addInjection(hint, 'user_steering'); }); } /** * Drains all pending tool calls that hit a breakpoint until the agent is idle. * Useful for negative tests to ensure no unwanted tools (like generalist) are called. * * @param onConfirmation Optional callback to inspect each confirmation before resolving. * Return true to skip the default resolveTool call (e.g. if you handled it). */ async drainBreakpointsUntilIdle( onConfirmation?: (confirmation: PendingConfirmation) => void | boolean, timeout = 60000, ) { while (true) { const event = await this.waitForNextEvent(timeout); if (event.type === 'idle') { break; } const confirmation = event.confirmation; const handled = onConfirmation?.(confirmation); if (!handled) { await this.resolveTool(confirmation); } } } getConfig(): Config { if (!this.config) throw new Error('AppRig not initialized'); return this.config; } async type(text: string) { if (!this.renderResult) throw new Error('AppRig not initialized'); await act(async () => { this.renderResult!.stdin.write(text); }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); } async pressEnter() { await this.type('\r'); } async pressKey(key: string) { if (!this.renderResult) throw new Error('AppRig not initialized'); await act(async () => { this.renderResult!.stdin.write(key); }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); } get lastFrame() { if (!this.renderResult) return ''; return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || ''); } getStaticOutput() { if (!this.renderResult) return ''; return stripAnsi(this.renderResult.stdout.lastFrame() || ''); } async waitForOutput(pattern: string | RegExp, timeout = 30000) { await this.waitUntil( () => { const frame = this.lastFrame; return typeof pattern === 'string' ? frame.includes(pattern) : pattern.test(frame); }, { timeout, message: `Timed out waiting for output: ${pattern}\nLast frame:\n${this.lastFrame}`, }, ); } async waitForIdle(timeout = 20000) { await this.waitForOutput('Type your message', timeout); } async sendMessage(text: string) { this.awaitingResponse = true; await this.type(text); await this.pressEnter(); } async unmount() { // Clean up global state for this session sessionStateMap.delete(this.sessionId); activeRigs.delete(this.sessionId); // Poison the chat recording service to prevent late writes to the test directory if (this.config) { const recordingService = this.config .getGeminiClient() ?.getChatRecordingService(); if (recordingService) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (recordingService as any).conversationFile = null; } } if (this.renderResult) { this.renderResult.unmount(); } await act(async () => { await new Promise((resolve) => setTimeout(resolve, 500)); }); vi.unstubAllEnvs(); coreEvents.removeAllListeners(); coreEvents.drainBacklogs(); MockShellExecutionService.reset(); ideContextStore.clear(); // Forcefully clear IdeClient singleton promise // eslint-disable-next-line @typescript-eslint/no-explicit-any (IdeClient as any).instancePromise = null; vi.clearAllMocks(); this.config = undefined; this.renderResult = undefined; if (this.testDir && fs.existsSync(this.testDir)) { try { fs.rmSync(this.testDir, { recursive: true, force: true }); } catch (e) { debugLogger.warn( `Failed to cleanup test directory ${this.testDir}:`, e, ); } } } } ================================================ FILE: packages/cli/src/test-utils/MockShellExecutionService.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; import type { ShellExecutionHandle, ShellExecutionResult, ShellOutputEvent, ShellExecutionConfig, } from '@google/gemini-cli-core'; export interface MockShellCommand { command: string | RegExp; result: Partial; events?: ShellOutputEvent[]; } type ShellExecutionServiceExecute = ( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ) => Promise; export class MockShellExecutionService { private static mockCommands: MockShellCommand[] = []; private static originalExecute: ShellExecutionServiceExecute | undefined; private static passthroughEnabled = false; /** * Registers the original implementation to allow falling back to real shell execution. */ static setOriginalImplementation( implementation: ShellExecutionServiceExecute, ) { this.originalExecute = implementation; } /** * Enables or disables passthrough to the real implementation when no mock matches. */ static setPassthrough(enabled: boolean) { this.passthroughEnabled = enabled; } static setMockCommands(commands: MockShellCommand[]) { this.mockCommands = commands; } static reset() { this.mockCommands = []; this.passthroughEnabled = false; this.writeToPty.mockClear(); this.kill.mockClear(); this.background.mockClear(); this.resizePty.mockClear(); this.scrollPty.mockClear(); } static async execute( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { const mock = this.mockCommands.find((m) => typeof m.command === 'string' ? m.command === commandToExecute : m.command.test(commandToExecute), ); const pid = Math.floor(Math.random() * 10000); if (mock) { if (mock.events) { for (const event of mock.events) { onOutputEvent(event); } } const result: ShellExecutionResult = { rawOutput: Buffer.from(mock.result.output || ''), output: mock.result.output || '', exitCode: mock.result.exitCode ?? 0, signal: mock.result.signal ?? null, error: mock.result.error ?? null, aborted: false, pid, executionMethod: 'none', ...mock.result, }; return { pid, result: Promise.resolve(result), }; } if (this.passthroughEnabled && this.originalExecute) { return this.originalExecute( commandToExecute, cwd, onOutputEvent, abortSignal, shouldUseNodePty, shellExecutionConfig, ); } return { pid, result: Promise.resolve({ rawOutput: Buffer.from(''), output: `Command not found: ${commandToExecute}`, exitCode: 127, signal: null, error: null, aborted: false, pid, executionMethod: 'none', }), }; } static writeToPty = vi.fn(); static isPtyActive = vi.fn(() => false); static onExit = vi.fn(() => () => {}); static kill = vi.fn(); static background = vi.fn(); static subscribe = vi.fn(() => () => {}); static resizePty = vi.fn(); static scrollPty = vi.fn(); } ================================================ FILE: packages/cli/src/test-utils/async.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { act } from 'react'; import { vi } from 'vitest'; // The waitFor from vitest doesn't properly wrap in act(), so we have to // implement our own like the one in @testing-library/react // or @testing-library/react-native // The version of waitFor from vitest is still fine to use if you aren't waiting // for React state updates. export async function waitFor( assertion: () => void | Promise, { timeout = 2000, interval = 50 } = {}, ): Promise { const startTime = Date.now(); while (true) { try { await assertion(); return; } catch (error) { if (Date.now() - startTime > timeout) { throw error; } await act(async () => { if (vi.isFakeTimers()) { await vi.advanceTimersByTimeAsync(interval); } else { await new Promise((resolve) => setTimeout(resolve, interval)); } }); } } } ================================================ FILE: packages/cli/src/test-utils/createExtension.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { type MCPServerConfig, type ExtensionInstallMetadata, type ExtensionSetting, type CustomTheme, } from '@google/gemini-cli-core'; import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, } from '../config/extensions/variables.js'; export function createExtension({ extensionsDir = 'extensions-dir', name = 'my-extension', version = '1.0.0', addContextFile = false, contextFileName = undefined as string | undefined, mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, themes = undefined as CustomTheme[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), JSON.stringify({ name, version, contextFileName, mcpServers, settings, themes, }), ); if (addContextFile) { fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context'); } if (contextFileName) { fs.writeFileSync(path.join(extDir, contextFileName), 'context'); } if (installMetadata) { fs.writeFileSync( path.join(extDir, INSTALL_METADATA_FILENAME), JSON.stringify(installMetadata), ); } return extDir; } ================================================ FILE: packages/cli/src/test-utils/customMatchers.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /// import { expect, type Assertion } from 'vitest'; import path from 'node:path'; import stripAnsi from 'strip-ansi'; import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; // RegExp to detect invalid characters: backspace, and ANSI escape codes // eslint-disable-next-line no-control-regex const invalidCharsRegex = /[\b\x1b]/; const callCountByTest = new Map(); export async function toMatchSvgSnapshot( this: Assertion, renderInstance: { lastFrameRaw?: (options?: { allowEmpty?: boolean }) => string; lastFrame?: (options?: { allowEmpty?: boolean }) => string; generateSvg: () => string; }, options?: { allowEmpty?: boolean; name?: string }, ) { const currentTestName = expect.getState().currentTestName; if (!currentTestName) { throw new Error('toMatchSvgSnapshot must be called within a test'); } const testPath = expect.getState().testPath; if (!testPath) { throw new Error('toMatchSvgSnapshot requires testPath'); } let textContent: string; if (renderInstance.lastFrameRaw) { textContent = renderInstance.lastFrameRaw({ allowEmpty: options?.allowEmpty, }); } else if (renderInstance.lastFrame) { textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty }); } else { throw new Error( 'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame', ); } const svgContent = renderInstance.generateSvg(); const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-'); const testId = testPath + ':' + currentTestName; let count = callCountByTest.get(testId) ?? 0; count++; callCountByTest.set(testId, count); const snapshotName = options?.name ?? (count > 1 ? `${currentTestName}-${count}` : currentTestName); const svgFileName = sanitize(path.basename(testPath).replace(/\.test\.tsx?$/, '')) + '-' + sanitize(snapshotName) + '.snap.svg'; const svgDir = path.join(path.dirname(testPath), '__snapshots__'); const svgFilePath = path.join(svgDir, svgFileName); // Assert the text matches standard snapshot, stripping ANSI for stability expect(stripAnsi(textContent)).toMatchSnapshot(); // Assert the SVG matches the file snapshot await expect(svgContent).toMatchFileSnapshot(svgFilePath); return { pass: true, message: () => '' }; } function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; for (let i = 0; i < buffer.lines.length; i++) { const line = buffer.lines[i]; if (line.includes('\n')) { pass = false; invalidLines.push({ line: i, content: line }); break; // Fail fast on newlines } if (invalidCharsRegex.test(line)) { pass = false; invalidLines.push({ line: i, content: line }); } } return { pass, message: () => `Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines .map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */ .join('\n')}`, actual: buffer.lines, expected: 'Lines with no line breaks, backspaces, or escape codes.', }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, toMatchSvgSnapshot, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); // Extend Vitest's `expect` interface with the custom matcher's type definition. declare module 'vitest' { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type interface Assertion extends CustomMatchers {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface AsymmetricMatchersContaining extends CustomMatchers {} interface CustomMatchers { toHaveOnlyValidCharacters(): T; toMatchSvgSnapshot(options?: { allowEmpty?: boolean; name?: string; }): Promise; } } ================================================ FILE: packages/cli/src/test-utils/fixtures/simple.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP"}]}]} ================================================ FILE: packages/cli/src/test-utils/fixtures/steering.responses ================================================ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]} ================================================ FILE: packages/cli/src/test-utils/mockCommandContext.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect } from 'vitest'; import { createMockCommandContext } from './mockCommandContext.js'; describe('createMockCommandContext', () => { it('should return a valid CommandContext object with default mocks', () => { const context = createMockCommandContext(); // Just a few spot checks to ensure the structure is correct // and functions are mocks. expect(context).toBeDefined(); expect(context.ui.addItem).toBeInstanceOf(Function); expect(vi.isMockFunction(context.ui.addItem)).toBe(true); }); it('should apply top-level overrides correctly', () => { const mockClear = vi.fn(); const overrides = { ui: { clear: mockClear, }, }; const context = createMockCommandContext(overrides); // Call the function to see if the override was used context.ui.clear(); // Assert that our specific mock was called, not the default expect(mockClear).toHaveBeenCalled(); // And that other defaults are still in place expect(vi.isMockFunction(context.ui.addItem)).toBe(true); }); it('should apply deeply nested overrides correctly', () => { // This is the most important test for factory's logic. const mockConfig = { getProjectRoot: () => '/test/project', getModel: () => 'gemini-pro', }; const overrides = { services: { agentContext: { config: mockConfig }, }, }; const context = createMockCommandContext(overrides); expect(context.services.agentContext).toBeDefined(); expect(context.services.agentContext?.config?.getModel()).toBe( 'gemini-pro', ); expect(context.services.agentContext?.config?.getProjectRoot()).toBe( '/test/project', ); // Verify a default property on the same nested object is still there expect(context.services.logger).toBeDefined(); }); }); ================================================ FILE: packages/cli/src/test-utils/mockCommandContext.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; import { mergeSettings, type LoadedSettings } from '../config/settings.js'; import type { GitService } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; // A utility type to make all properties of an object, and its nested objects, partial. type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial; } : T; /** * Creates a deep, fully-typed mock of the CommandContext for use in tests. * All functions are pre-mocked with `vi.fn()`. * * @param overrides - A deep partial object to override any default mock values. * @returns A complete, mocked CommandContext object. */ export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const defaultMocks: CommandContext = { invocation: { raw: '', name: '', args: '', }, services: { agentContext: null, settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, logger: { log: vi.fn(), logMessage: vi.fn(), saveCheckpoint: vi.fn(), loadCheckpoint: vi.fn().mockResolvedValue([]), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, ui: { addItem: vi.fn(), clear: vi.fn(), setDebugMessage: vi.fn(), pendingItem: null, setPendingItem: vi.fn(), loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { sessionShellAllowlist: new Set(), stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, metrics: { models: {}, tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, totalDurationMs: 0, totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, }, } as SessionStatsState, }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { const sourceValue = source[key]; const targetValue = output[key]; if ( // We only want to recursively merge plain objects Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. output[key] = sourceValue; } } } return output; }; return merge(defaultMocks, overrides); }; ================================================ FILE: packages/cli/src/test-utils/mockConfig.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; import { NoopSandboxManager } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { createTestMergedSettings, type LoadedSettings, type Settings, } from '../config/settings.js'; /** * Creates a mocked Config object with default values and allows overrides. */ export const createMockConfig = (overrides: Partial = {}): Config => ({ getSandbox: vi.fn(() => undefined), getQuestion: vi.fn(() => ''), isInteractive: vi.fn(() => false), isInitialized: vi.fn(() => true), setTerminalBackground: vi.fn(), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), initialize: vi.fn().mockResolvedValue(undefined), }, getDebugMode: vi.fn(() => false), getProjectRoot: vi.fn(() => '/'), refreshAuth: vi.fn().mockResolvedValue(undefined), getRemoteAdminSettings: vi.fn(() => undefined), initialize: vi.fn().mockResolvedValue(undefined), getPolicyEngine: vi.fn(() => ({})), getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })), getHookSystem: vi.fn(() => ({ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), })), getListExtensions: vi.fn(() => false), getExtensions: vi.fn(() => []), getListSessions: vi.fn(() => false), getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), getAcpMode: vi.fn(() => false), isBrowserLaunchSuppressed: vi.fn(() => false), setRemoteAdminSettings: vi.fn(), isYoloModeDisabled: vi.fn(() => false), isPlanEnabled: vi.fn(() => false), getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true), getApprovedPlanPath: vi.fn(() => undefined), getCoreTools: vi.fn(() => []), getAllowedTools: vi.fn(() => []), getApprovalMode: vi.fn(() => 'default'), getFileFilteringRespectGitIgnore: vi.fn(() => true), getOutputFormat: vi.fn(() => 'text'), getUsageStatisticsEnabled: vi.fn(() => true), getScreenReader: vi.fn(() => false), getGeminiMdFileCount: vi.fn(() => 0), getDeferredCommand: vi.fn(() => undefined), getFileSystemService: vi.fn(() => ({})), clientVersion: '1.0.0', getModel: vi.fn().mockReturnValue('gemini-pro'), getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'), getToolRegistry: vi.fn().mockReturnValue({ getTools: vi.fn().mockReturnValue([]), getAllTools: vi.fn().mockReturnValue([]), }), getAgentRegistry: vi.fn().mockReturnValue({}), getPromptRegistry: vi.fn().mockReturnValue({}), getResourceRegistry: vi.fn().mockReturnValue({}), getSkillManager: vi.fn().mockReturnValue({ isAdminEnabled: vi.fn().mockReturnValue(false), }), getFileService: vi.fn().mockReturnValue({}), getGitService: vi.fn().mockResolvedValue({}), getUserMemory: vi.fn().mockReturnValue(''), getSystemInstructionMemory: vi.fn().mockReturnValue(''), getSessionMemory: vi.fn().mockReturnValue(''), getGeminiMdFilePaths: vi.fn().mockReturnValue([]), getShowMemoryUsage: vi.fn().mockReturnValue(false), getAccessibility: vi.fn().mockReturnValue({}), getTelemetryEnabled: vi.fn().mockReturnValue(false), getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''), getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'), getTelemetryTarget: vi.fn().mockReturnValue(''), getTelemetryOutfile: vi.fn().mockReturnValue(undefined), getTelemetryUseCollector: vi.fn().mockReturnValue(false), getTelemetryUseCliAuth: vi.fn().mockReturnValue(false), getGeminiClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), }), updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined), getModelRouterService: vi.fn().mockReturnValue({}), getModelAvailabilityService: vi.fn().mockReturnValue({}), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true), getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true), getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true), getFileFilteringOptions: vi.fn().mockReturnValue({}), getCustomExcludes: vi.fn().mockReturnValue([]), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getProxy: vi.fn().mockReturnValue(undefined), getBugCommand: vi.fn().mockReturnValue(undefined), getExtensionManagement: vi.fn().mockReturnValue(true), getExtensionLoader: vi.fn().mockReturnValue({}), getEnabledExtensions: vi.fn().mockReturnValue([]), getEnableExtensionReloading: vi.fn().mockReturnValue(false), getDisableLLMCorrection: vi.fn().mockReturnValue(false), getNoBrowser: vi.fn().mockReturnValue(false), getAgentsSettings: vi.fn().mockReturnValue({}), getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), getIdeMode: vi.fn().mockReturnValue(false), getFolderTrust: vi.fn().mockReturnValue(true), isTrustedFolder: vi.fn().mockReturnValue(true), getCompressionThreshold: vi.fn().mockResolvedValue(undefined), getUserCaching: vi.fn().mockResolvedValue(false), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''), getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''), isInteractiveShellEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), isSkillsSupportEnabled: vi.fn().mockReturnValue(false), reloadSkills: vi.fn().mockResolvedValue(undefined), reloadAgents: vi.fn().mockResolvedValue(undefined), getUseRipgrep: vi.fn().mockReturnValue(false), getEnableInteractiveShell: vi.fn().mockReturnValue(false), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn().mockReturnValue(false), getRetryFetchErrors: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), getShellExecutionConfig: vi.fn().mockReturnValue({ sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], enableEnvironmentVariableRedaction: false, }, }), setShellExecutionConfig: vi.fn(), getEnableToolOutputTruncation: vi.fn().mockReturnValue(true), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000), getTruncateToolOutputLines: vi.fn().mockReturnValue(100), getNextCompressionTruncationId: vi.fn().mockReturnValue(1), getUseWriteTodos: vi.fn().mockReturnValue(false), getFileExclusions: vi.fn().mockReturnValue({}), getEnableHooks: vi.fn().mockReturnValue(true), getEnableHooksUI: vi.fn().mockReturnValue(true), getMcpClientManager: vi.fn().mockReturnValue({ getMcpInstructions: vi.fn().mockReturnValue(''), getMcpServers: vi.fn().mockReturnValue({}), getLastError: vi.fn().mockReturnValue(undefined), }), setUserInteractedWithMcp: vi.fn(), emitMcpDiagnostic: vi.fn(), getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false), getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getDisabledSkills: vi.fn().mockReturnValue([]), getExperimentalJitContext: vi.fn().mockReturnValue(false), getTerminalBackground: vi.fn().mockReturnValue(undefined), getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'), getQuotaErrorOccurred: vi.fn().mockReturnValue(false), getMaxSessionTurns: vi.fn().mockReturnValue(100), getExcludeTools: vi.fn().mockReturnValue(new Set()), getAllowedMcpServers: vi.fn().mockReturnValue([]), getBlockedMcpServers: vi.fn().mockReturnValue([]), getExperiments: vi.fn().mockReturnValue(undefined), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), validatePathAccess: vi.fn().mockReturnValue(null), getUseAlternateBuffer: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; /** * Creates a mocked LoadedSettings object for tests. */ export function createMockSettings( overrides: Record = {}, ): LoadedSettings { const merged = createTestMergedSettings( (overrides['merged'] as Partial) || {}, ); return { system: { settings: {} }, systemDefaults: { settings: {} }, user: { settings: {} }, workspace: { settings: {} }, errors: [], ...overrides, merged, } as unknown as LoadedSettings; } ================================================ FILE: packages/cli/src/test-utils/mockDebugLogger.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; import { format } from 'node:util'; export function createMockDebugLogger(options: { stripAnsi?: boolean } = {}) { const emitConsoleLog = vi.fn(); const debugLogger = { log: vi.fn((message: unknown, ...args: unknown[]) => { let formatted = typeof message === 'string' ? format(message, ...args) : message; if (options.stripAnsi && typeof formatted === 'string') { formatted = stripAnsi(formatted); } emitConsoleLog('log', formatted); }), error: vi.fn((message: unknown, ...args: unknown[]) => { let formatted = typeof message === 'string' ? format(message, ...args) : message; if (options.stripAnsi && typeof formatted === 'string') { formatted = stripAnsi(formatted); } emitConsoleLog('error', formatted); }), warn: vi.fn((message: unknown, ...args: unknown[]) => { let formatted = typeof message === 'string' ? format(message, ...args) : message; if (options.stripAnsi && typeof formatted === 'string') { formatted = stripAnsi(formatted); } emitConsoleLog('warn', formatted); }), debug: vi.fn(), info: vi.fn(), }; return { emitConsoleLog, debugLogger }; } /** * A helper specifically designed for `vi.mock('@google/gemini-cli-core', ...)` to easily * mock both `debugLogger` and `coreEvents.emitConsoleLog`. * * Example: * ```typescript * vi.mock('@google/gemini-cli-core', async (importOriginal) => { * const { mockCoreDebugLogger } = await import('../../test-utils/mockDebugLogger.js'); * return mockCoreDebugLogger( * await importOriginal(), * { stripAnsi: true } * ); * }); * ``` */ export function mockCoreDebugLogger>( actual: T, options?: { stripAnsi?: boolean }, ): T { const { emitConsoleLog, debugLogger } = createMockDebugLogger(options); return { ...actual, coreEvents: { // eslint-disable-next-line no-restricted-syntax ...(typeof actual['coreEvents'] === 'object' && actual['coreEvents'] !== null ? actual['coreEvents'] : {}), emitConsoleLog, }, debugLogger, } as T; } ================================================ FILE: packages/cli/src/test-utils/persistentStateFake.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi } from 'vitest'; /** * A fake implementation of PersistentState for testing. * It keeps state in memory and provides spies for get and set. */ export class FakePersistentState { private data: Record = {}; get = vi.fn().mockImplementation((key: string) => this.data[key]); set = vi.fn().mockImplementation((key: string, value: unknown) => { this.data[key] = value; }); /** * Helper to reset the fake state between tests. */ reset() { this.data = {}; this.get.mockClear(); this.set.mockClear(); } /** * Helper to clear mock call history without wiping data. */ mockClear() { this.get.mockClear(); this.set.mockClear(); } /** * Helper to set initial data for the fake. */ setData(data: Record) { this.data = { ...data }; } } ================================================ FILE: packages/cli/src/test-utils/render.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { useState, useEffect, act } from 'react'; import { Text } from 'ink'; import { renderHook, render } from './render.js'; import { waitFor } from './async.js'; describe('render', () => { it('should render a component', async () => { const { lastFrame, waitUntilReady, unmount } = render( Hello World, ); await waitUntilReady(); expect(lastFrame()).toBe('Hello World\n'); unmount(); }); it('should support rerender', async () => { const { lastFrame, rerender, waitUntilReady, unmount } = render( Hello, ); await waitUntilReady(); expect(lastFrame()).toBe('Hello\n'); await act(async () => { rerender(World); }); await waitUntilReady(); expect(lastFrame()).toBe('World\n'); unmount(); }); it('should support unmount', async () => { const cleanupMock = vi.fn(); function TestComponent() { useEffect(() => cleanupMock, []); return Hello; } const { unmount, waitUntilReady } = render(); await waitUntilReady(); unmount(); expect(cleanupMock).toHaveBeenCalled(); }); }); describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { const useTestHook = ({ value }: { value: number }) => { const [count, setCount] = useState(0); useEffect(() => { setCount((c) => c + 1); }, [value]); return { count, value }; }; const { result, rerender, waitUntilReady, unmount } = renderHook( useTestHook, { initialProps: { value: 1 }, }, ); await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props await act(async () => { rerender({ value: 2 }); }); await waitUntilReady(); expect(result.current.value).toBe(2); await waitFor(() => expect(result.current.count).toBe(2)); // Rerender without arguments should use previous props (value: 2) // This would previously crash or pass undefined if not fixed await act(async () => { rerender(); }); await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change await waitFor(() => expect(result.current.count).toBe(2)); unmount(); }); it('should handle initial render without props', async () => { const useTestHook = () => { const [count, setCount] = useState(0); return { count, increment: () => setCount((c) => c + 1) }; }; const { result, rerender, waitUntilReady, unmount } = renderHook(useTestHook); await waitUntilReady(); expect(result.current.count).toBe(0); await act(async () => { rerender(); }); await waitUntilReady(); expect(result.current.count).toBe(0); unmount(); }); it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; const { result, rerender, waitUntilReady, unmount } = renderHook( useTestHook, { initialProps: 'initial' as string | undefined, }, ); await waitUntilReady(); expect(result.current).toBe('initial'); await act(async () => { rerender(undefined); }); await waitUntilReady(); expect(result.current).toBeUndefined(); unmount(); }); }); ================================================ FILE: packages/cli/src/test-utils/render.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render as inkRenderDirect, type Instance as InkInstance, type RenderOptions, } from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import { Terminal } from '@xterm/headless'; import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; import type React from 'react'; import { act, useState } from 'react'; import type { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { MouseProvider } from '../ui/contexts/MouseContext.js'; import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; import { StreamingContext } from '../ui/contexts/StreamingContext.js'; import { type UIActions, UIActionsContext, } from '../ui/contexts/UIActionsContext.js'; import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; import { OverflowProvider, useOverflowActions, useOverflowState, type OverflowActions, type OverflowState, } from '../ui/contexts/OverflowContext.js'; import { type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; import { createMockSettings } from './settings.js'; import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; import { generateSvgForTerminal } from './svg.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; export const persistentStateMock = new FakePersistentState(); if (process.env['NODE_ENV'] === 'test') { // We mock NODE_ENV to development during tests that use render.tsx // so that animations (which check process.env.NODE_ENV !== 'test') // are actually tested. We mutate process.env directly here because // vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts // after each test. process.env['NODE_ENV'] = 'development'; } vi.mock('../utils/persistentState.js', () => ({ get persistentState() { return persistentStateMock; }, })); vi.mock('../ui/utils/terminalUtils.js', () => ({ isLowColorDepth: vi.fn(() => false), getColorDepth: vi.fn(() => 24), isITerm2: vi.fn(() => false), })); type TerminalState = { terminal: Terminal; cols: number; rows: number; }; type RenderMetrics = Parameters>[0]; interface InkRenderMetrics extends RenderMetrics { output: string; staticOutput?: string; } function isInkRenderMetrics( metrics: RenderMetrics, ): metrics is InkRenderMetrics { const m = metrics as Record; return ( typeof m === 'object' && m !== null && 'output' in m && // eslint-disable-next-line no-restricted-syntax typeof m['output'] === 'string' ); } class XtermStdout extends EventEmitter { private state: TerminalState; private pendingWrites = 0; private renderCount = 0; private queue: { promise: Promise }; isTTY = true; getColorDepth(): number { return 24; } private lastRenderOutput: string | undefined = undefined; private lastRenderStaticContent: string | undefined = undefined; constructor(state: TerminalState, queue: { promise: Promise }) { super(); this.state = state; this.queue = queue; } get columns() { return this.state.terminal.cols; } get rows() { return this.state.terminal.rows; } get frames(): string[] { return []; } write = (data: string) => { this.pendingWrites++; this.queue.promise = this.queue.promise.then(async () => { await new Promise((resolve) => this.state.terminal.write(data, resolve), ); this.pendingWrites--; }); }; clear = () => { this.state.terminal.reset(); this.lastRenderOutput = undefined; this.lastRenderStaticContent = undefined; }; dispose = () => { this.state.terminal.dispose(); }; onRender = (staticContent: string, output: string) => { this.renderCount++; this.lastRenderStaticContent = staticContent; this.lastRenderOutput = output; this.emit('render'); }; private normalizeFrame = (text: string): string => text.replace(/\r\n/g, '\n'); generateSvg = (): string => generateSvgForTerminal(this.state.terminal); lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => { const result = (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); const normalized = this.normalizeFrame(result); if (normalized === '' && !options.allowEmpty) { throw new Error( 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', ); } return normalized; }; lastFrame = (options: { allowEmpty?: boolean } = {}) => { const buffer = this.state.terminal.buffer.active; const allLines: string[] = []; for (let i = 0; i < buffer.length; i++) { allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); } const trimmed = [...allLines]; while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { trimmed.pop(); } const result = trimmed.join('\n'); const normalized = this.normalizeFrame(result); if (normalized === '' && !options.allowEmpty) { throw new Error( 'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', ); } return normalized === '' ? normalized : normalized + '\n'; }; async waitUntilReady() { const startRenderCount = this.renderCount; if (!vi.isFakeTimers()) { // Give Ink a chance to start its rendering loop await new Promise((resolve) => setImmediate(resolve)); } await act(async () => { if (vi.isFakeTimers()) { await vi.advanceTimersByTimeAsync(50); } else { // Wait for at least one render to be called if we haven't rendered yet or since start of this call, // but don't wait forever as some renders might be synchronous or skipped. if (this.renderCount === startRenderCount) { const renderPromise = new Promise((resolve) => this.once('render', resolve), ); const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 50), ); await Promise.race([renderPromise, timeoutPromise]); } } }); let attempts = 0; const maxAttempts = 50; let lastCurrent = ''; let lastExpected = ''; while (attempts < maxAttempts) { // Ensure all pending writes to the terminal are processed. await this.queue.promise; const currentFrame = stripAnsi( this.lastFrame({ allowEmpty: true }), ).trim(); const expectedFrame = this.normalizeFrame( stripAnsi( (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), ), ).trim(); lastCurrent = currentFrame; lastExpected = expectedFrame; const isMatch = () => { if (expectedFrame === '...') { return currentFrame !== ''; } // If both are empty, it's a match. // We consider undefined lastRenderOutput as effectively empty for this check // to support hook testing where Ink may skip rendering completely. if ( (this.lastRenderOutput === undefined || expectedFrame === '') && currentFrame === '' ) { return true; } if (this.lastRenderOutput === undefined) { return false; } // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. if (expectedFrame === '' || currentFrame === '') { return false; } // Check if the current frame contains the expected content. // We use includes because xterm might have some formatting or // extra whitespace that Ink doesn't account for in its raw output metrics. return currentFrame.includes(expectedFrame); }; if (this.pendingWrites === 0 && isMatch()) { return; } attempts++; await act(async () => { if (vi.isFakeTimers()) { await vi.advanceTimersByTimeAsync(10); } else { await new Promise((resolve) => setTimeout(resolve, 10)); } }); } throw new Error( `waitUntilReady() timed out after ${maxAttempts} attempts.\n` + `Expected content (stripped ANSI):\n"${lastExpected}"\n` + `Actual content (stripped ANSI):\n"${lastCurrent}"\n` + `Pending writes: ${this.pendingWrites}\n` + `Render count: ${this.renderCount}`, ); } } class XtermStderr extends EventEmitter { private state: TerminalState; private pendingWrites = 0; private queue: { promise: Promise }; isTTY = true; constructor(state: TerminalState, queue: { promise: Promise }) { super(); this.state = state; this.queue = queue; } write = (data: string) => { this.pendingWrites++; this.queue.promise = this.queue.promise.then(async () => { await new Promise((resolve) => this.state.terminal.write(data, resolve), ); this.pendingWrites--; }); }; dispose = () => { this.state.terminal.dispose(); }; lastFrame = () => ''; } class XtermStdin extends EventEmitter { isTTY = true; data: string | null = null; constructor(options: { isTTY?: boolean } = {}) { super(); this.isTTY = options.isTTY ?? true; } write = (data: string) => { this.data = data; this.emit('readable'); this.emit('data', data); }; setEncoding() {} setRawMode() {} resume() {} pause() {} ref() {} unref() {} read = () => { const { data } = this; this.data = null; return data; }; } export type RenderInstance = { rerender: (tree: React.ReactElement) => void; unmount: () => void; cleanup: () => void; stdout: XtermStdout; stderr: XtermStderr; stdin: XtermStdin; frames: string[]; lastFrame: (options?: { allowEmpty?: boolean }) => string; lastFrameRaw: (options?: { allowEmpty?: boolean }) => string; generateSvg: () => string; terminal: Terminal; waitUntilReady: () => Promise; capturedOverflowState: OverflowState | undefined; capturedOverflowActions: OverflowActions | undefined; }; const instances: InkInstance[] = []; // Wrapper around ink's render that ensures act() is called and uses Xterm for output export const render = ( tree: React.ReactElement, terminalWidth?: number, ): Omit< RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions' > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct // value was used (e.g. 40 rows). The alternatives to make things worse are // windows unfortunately with odd duplicate content in the backbuffer // which does not match actual behavior in xterm.js on windows. const rows = 1000; const terminal = new Terminal({ cols, rows, allowProposedApi: true, convertEol: true, }); const state: TerminalState = { terminal, cols, rows, }; const writeQueue = { promise: Promise.resolve() }; const stdout = new XtermStdout(state, writeQueue); const stderr = new XtermStderr(state, writeQueue); const stdin = new XtermStdin(); let instance!: InkInstance; stdout.clear(); act(() => { instance = inkRenderDirect(tree, { stdout: stdout as unknown as NodeJS.WriteStream, stderr: stderr as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, debug: false, exitOnCtrlC: false, patchConsole: false, onRender: (metrics: RenderMetrics) => { const output = isInkRenderMetrics(metrics) ? metrics.output : '...'; const staticOutput = isInkRenderMetrics(metrics) ? (metrics.staticOutput ?? '') : ''; stdout.onRender(staticOutput, output); }, }); }); instances.push(instance); return { rerender: (newTree: React.ReactElement) => { act(() => { stdout.clear(); instance.rerender(newTree); }); }, unmount: () => { act(() => { instance.unmount(); }); stdout.dispose(); stderr.dispose(); }, cleanup: instance.cleanup, stdout, stderr, stdin, frames: stdout.frames, lastFrame: stdout.lastFrame, lastFrameRaw: stdout.lastFrameRaw, generateSvg: stdout.generateSvg, terminal: state.terminal, waitUntilReady: () => stdout.waitUntilReady(), }; }; export const cleanup = () => { for (const instance of instances) { act(() => { instance.unmount(); }); instance.cleanup(); } instances.length = 0; }; export const simulateClick = async ( stdin: XtermStdin, col: number, row: number, button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right ) => { // Terminal mouse events are 1-based, so convert if necessary. const mouseEventString = `\x1b[<${button};${col};${row}M`; await act(async () => { stdin.write(mouseEventString); }); }; export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. const baseMockUiState = { history: [], renderMarkdown: true, streamingState: StreamingState.Idle, terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: 'black' as const, cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, backgroundShells: new Map(), backgroundShellHeight: 0, quota: { userTier: undefined, stats: undefined, proQuotaRequest: null, validationRequest: null, }, hintMode: false, hintBuffer: '', bannerData: { defaultText: '', warningText: '', }, bannerVisible: false, nightly: false, updateInfo: null, pendingHistoryItems: [], }; export const mockAppState: AppState = { version: '1.2.3', startupWarnings: [], }; const mockUIActions: UIActions = { handleThemeSelect: vi.fn(), closeThemeDialog: vi.fn(), handleThemeHighlight: vi.fn(), handleAuthSelect: vi.fn(), setAuthState: vi.fn(), onAuthError: vi.fn(), handleEditorSelect: vi.fn(), exitEditorDialog: vi.fn(), exitPrivacyNotice: vi.fn(), closeSettingsDialog: vi.fn(), closeModelDialog: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), openPermissionsDialog: vi.fn(), openSessionBrowser: vi.fn(), closeSessionBrowser: vi.fn(), handleResumeSession: vi.fn(), handleDeleteSession: vi.fn(), closePermissionsDialog: vi.fn(), setShellModeActive: vi.fn(), vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), handleValidationChoice: vi.fn(), handleOverageMenuChoice: vi.fn(), handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), setShortcutsHelpVisible: vi.fn(), setCleanUiDetailsVisible: vi.fn(), toggleCleanUiDetailsVisible: vi.fn(), revealCleanUiDetailsTemporarily: vi.fn(), handleWarning: vi.fn(), setEmbeddedShellFocused: vi.fn(), dismissBackgroundShell: vi.fn(), setActiveBackgroundShellPid: vi.fn(), setIsBackgroundShellListOpen: vi.fn(), setAuthContext: vi.fn(), onHintInput: vi.fn(), onHintBackspace: vi.fn(), onHintClear: vi.fn(), onHintSubmit: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), getPreferredEditor: vi.fn(), clearAccountSuspension: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; let capturedOverflowActions: OverflowActions | undefined; const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ children, }) => { capturedOverflowState = useOverflowState(); capturedOverflowActions = useOverflowActions(); return <>{children}; }; export const renderWithProviders = async ( component: React.ReactElement, { shellFocus = true, settings = mockSettings, uiState: providedUiState, width, mouseEventsEnabled = false, config, uiActions, persistentState, appState = mockAppState, }: { shellFocus?: boolean; settings?: LoadedSettings; uiState?: Partial; width?: number; mouseEventsEnabled?: boolean; config?: Config; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; set?: typeof persistentStateMock.set; }; appState?: AppState; } = {}, ): Promise< RenderInstance & { simulateClick: ( col: number, row: number, button?: 0 | 1 | 2, ) => Promise; } > => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); }, }, ) as UIState; if (persistentState?.get) { persistentStateMock.get.mockImplementation(persistentState.get); } if (persistentState?.set) { persistentStateMock.set.mockImplementation(persistentState.set); } persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; if (!config) { config = await loadCliConfig( settings.merged, 'random-session-id', {} as unknown as CliArgs, { cwd: '/' }, ); } const mainAreaWidth = terminalWidth; const finalUiState = { ...baseState, terminalWidth, mainAreaWidth, }; themeManager.setTerminalBackground(baseState.terminalBackgroundColor); const themeName = pickDefaultThemeName( baseState.terminalBackgroundColor, themeManager.getAllThemes(), DEFAULT_THEME.name, DefaultLight.name, ); themeManager.setActiveTheme(themeName); const finalUIActions = { ...mockUIActions, ...uiActions }; const allToolCalls = (finalUiState.pendingHistoryItems || []) .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .flatMap((item) => item.tools); capturedOverflowState = undefined; capturedOverflowActions = undefined; const wrapWithProviders = (comp: React.ReactElement) => ( {comp} ); const renderResult = render(wrapWithProviders(component), terminalWidth); return { ...renderResult, rerender: (newComponent: React.ReactElement) => { renderResult.rerender(wrapWithProviders(newComponent)); }, capturedOverflowState, capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => simulateClick(renderResult.stdin, col, row, button), }; }; export function renderHook( renderCallback: (props: Props) => Result, options?: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; }, ): { result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; } { const result = { current: undefined as unknown as Result }; let currentProps = options?.initialProps as Props; function TestComponent({ renderCallback, props, }: { renderCallback: (props: Props) => Result; props: Props; }) { result.current = renderCallback(props); return null; } const Wrapper = options?.wrapper || (({ children }) => <>{children}); let inkRerender: (tree: React.ReactElement) => void = () => {}; let unmount: () => void = () => {}; let waitUntilReady: () => Promise = async () => {}; let generateSvg: () => string = () => ''; act(() => { const renderResult = render( , ); inkRerender = renderResult.rerender; unmount = renderResult.unmount; waitUntilReady = renderResult.waitUntilReady; generateSvg = renderResult.generateSvg; }); function rerender(props?: Props) { if (arguments.length > 0) { currentProps = props as Props; } act(() => { inkRerender( , ); }); } return { result, rerender, unmount, waitUntilReady, generateSvg }; } export async function renderHookWithProviders( renderCallback: (props: Props) => Result, options: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; // Options for renderWithProviders shellFocus?: boolean; settings?: LoadedSettings; uiState?: Partial; width?: number; mouseEventsEnabled?: boolean; config?: Config; } = {}, ): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; }> { const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; let forceUpdateFn: (() => void) | undefined; function TestComponent({ initialProps }: { initialProps: Props }) { const [props, setProps] = useState(initialProps); const [, forceUpdate] = useState(0); setPropsFn = setProps; forceUpdateFn = () => forceUpdate((n) => n + 1); result.current = renderCallback(props); return null; } const Wrapper = options.wrapper || (({ children }) => <>{children}); let renderResult: ReturnType; await act(async () => { renderResult = await renderWithProviders( {} , options, ); }); function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); } }); } return { result, rerender, unmount: () => { act(() => { renderResult.unmount(); }); }, waitUntilReady: () => renderResult.waitUntilReady(), generateSvg: () => renderResult.generateSvg(), }; } ================================================ FILE: packages/cli/src/test-utils/settings.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { LoadedSettings, createTestMergedSettings, type SettingsError, } from '../config/settings.js'; export interface MockSettingsFile { settings: any; originalSettings: any; path: string; } interface CreateMockSettingsOptions { system?: MockSettingsFile; systemDefaults?: MockSettingsFile; user?: MockSettingsFile; workspace?: MockSettingsFile; isTrusted?: boolean; errors?: SettingsError[]; merged?: any; [key: string]: any; } /** * Creates a mock LoadedSettings object for testing. * * @param overrides - Partial settings or LoadedSettings properties to override. * If 'merged' is provided, it overrides the computed merged settings. * Any functions in overrides are assigned directly to the LoadedSettings instance. */ export const createMockSettings = ( overrides: CreateMockSettingsOptions = {}, ): LoadedSettings => { const { system, systemDefaults, user, workspace, isTrusted, errors, merged: mergedOverride, ...settingsOverrides } = overrides; const loaded = new LoadedSettings( (system as any) || { path: '', settings: {}, originalSettings: {} }, (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], ); if (mergedOverride) { // @ts-expect-error - overriding private field for testing loaded._merged = createTestMergedSettings(mergedOverride); } // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { (loaded as any)[key] = overrides[key]; } } return loaded; }; ================================================ FILE: packages/cli/src/test-utils/svg.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { Terminal } from '@xterm/headless'; export const generateSvgForTerminal = (terminal: Terminal): string => { const activeBuffer = terminal.buffer.active; const getHexColor = ( isRGB: boolean, isPalette: boolean, isDefault: boolean, colorCode: number, ): string | null => { if (isDefault) return null; if (isRGB) { return `#${colorCode.toString(16).padStart(6, '0')}`; } if (isPalette) { if (colorCode >= 0 && colorCode <= 15) { return ( [ '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', ][colorCode] || null ); } else if (colorCode >= 16 && colorCode <= 231) { const v = [0, 95, 135, 175, 215, 255]; const c = colorCode - 16; const b = v[c % 6]; const g = v[Math.floor(c / 6) % 6]; const r = v[Math.floor(c / 36) % 6]; return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`; } else if (colorCode >= 232 && colorCode <= 255) { const gray = 8 + (colorCode - 232) * 10; const hex = gray.toString(16).padStart(2, '0'); return `#${hex}${hex}${hex}`; } } return null; }; const escapeXml = (unsafe: string): string => // eslint-disable-next-line no-control-regex unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => { switch (c) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case "'": return '''; case '"': return '"'; default: return ''; } }); const charWidth = 9; const charHeight = 17; const padding = 10; // Find the actual number of rows with content to avoid rendering trailing blank space. let contentRows = terminal.rows; for (let y = terminal.rows - 1; y >= 0; y--) { const line = activeBuffer.getLine(y); if (line && line.translateToString(true).trim().length > 0) { contentRows = y + 1; break; } } if (contentRows === 0) contentRows = 1; // Minimum 1 row const width = terminal.cols * charWidth + padding * 2; const height = contentRows * charHeight + padding * 2; let svg = ` `; svg += ` `; svg += ` `; // Terminal background svg += ` `; for (let y = 0; y < contentRows; y++) { const line = activeBuffer.getLine(y); if (!line) continue; let currentFgHex: string | null = null; let currentBgHex: string | null = null; let currentIsBold = false; let currentIsItalic = false; let currentIsUnderline = false; let currentBlockStartCol = -1; let currentBlockText = ''; let currentBlockNumCells = 0; const finalizeBlock = (_endCol: number) => { if (currentBlockStartCol !== -1) { if (currentBlockText.length > 0) { const xPos = currentBlockStartCol * charWidth; const yPos = y * charHeight; if (currentBgHex) { const rectWidth = currentBlockNumCells * charWidth; svg += ` `; } if (currentBlockText.trim().length > 0 || currentIsUnderline) { const fill = currentFgHex || '#ffffff'; // Default text color const textWidth = currentBlockNumCells * charWidth; let extraAttrs = ''; if (currentIsBold) extraAttrs += ' font-weight="bold"'; if (currentIsItalic) extraAttrs += ' font-style="italic"'; if (currentIsUnderline) extraAttrs += ' text-decoration="underline"'; // Use textLength to ensure the block fits exactly into its designated cells const textElement = `${escapeXml(currentBlockText)}`; svg += ` ${textElement}\n`; } } } }; for (let x = 0; x < line.length; x++) { const cell = line.getCell(x); if (!cell) continue; const cellWidth = cell.getWidth(); if (cellWidth === 0) continue; // Skip continuation cells of wide characters let fgHex = getHexColor( cell.isFgRGB(), cell.isFgPalette(), cell.isFgDefault(), cell.getFgColor(), ); let bgHex = getHexColor( cell.isBgRGB(), cell.isBgPalette(), cell.isBgDefault(), cell.getBgColor(), ); if (cell.isInverse()) { const tempFgHex = fgHex; fgHex = bgHex || '#000000'; bgHex = tempFgHex || '#ffffff'; } const isBold = !!cell.isBold(); const isItalic = !!cell.isItalic(); const isUnderline = !!cell.isUnderline(); let chars = cell.getChars(); if (chars === '') chars = ' '.repeat(cellWidth); if ( fgHex !== currentFgHex || bgHex !== currentBgHex || isBold !== currentIsBold || isItalic !== currentIsItalic || isUnderline !== currentIsUnderline || currentBlockStartCol === -1 ) { finalizeBlock(x); currentFgHex = fgHex; currentBgHex = bgHex; currentIsBold = isBold; currentIsItalic = isItalic; currentIsUnderline = isUnderline; currentBlockStartCol = x; currentBlockText = chars; currentBlockNumCells = cellWidth; } else { currentBlockText += chars; currentBlockNumCells += cellWidth; } } finalizeBlock(line.length); } svg += ` \n`; return svg; }; ================================================ FILE: packages/cli/src/ui/App.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; import { createMockSettings } from '../test-utils/settings.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; import { type UIState } from './contexts/UIStateContext.js'; import { StreamingState } from './types.js'; import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; vi.mock('ink', async (importOriginal) => { const original = await importOriginal(); return { ...original, useIsScreenReaderEnabled: vi.fn(), }; }); vi.mock('./components/DialogManager.js', () => ({ DialogManager: () => DialogManager, })); vi.mock('./components/Composer.js', () => ({ Composer: () => Composer, })); vi.mock('./components/Notifications.js', async () => { const { Text, Box } = await import('ink'); return { Notifications: () => ( Notifications ), }; }); vi.mock('./components/QuittingDisplay.js', () => ({ QuittingDisplay: () => Quitting..., })); vi.mock('./components/HistoryItemDisplay.js', () => ({ HistoryItemDisplay: () => HistoryItemDisplay, })); vi.mock('./components/Footer.js', async () => { const { Text, Box } = await import('ink'); return { Footer: () => ( Footer ), }; }); describe('App', () => { beforeEach(() => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); }); const mockUIState: Partial = { streamingState: StreamingState.Idle, cleanUiDetailsVisible: true, quittingMessages: null, dialogsVisible: false, mainControlsRef: { current: null, } as unknown as React.MutableRefObject, rootUiRef: { current: null, } as unknown as React.MutableRefObject, historyManager: { addItem: vi.fn(), history: [], updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), }, history: [], pendingHistoryItems: [], pendingGeminiHistoryItems: [], bannerData: { defaultText: 'Mock Banner Text', warningText: '', }, backgroundShells: new Map(), }; it('should render main content and composer when not quitting', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); unmount(); }); it('should render quitting display when quittingMessages is set', async () => { const quittingUIState = { ...mockUIState, quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Quitting...'); unmount(); }); it('should render full history in alternate buffer mode when quittingMessages is set', async () => { const quittingUIState = { ...mockUIState, quittingMessages: [{ id: 1, type: 'user', text: 'test' }], history: [{ id: 1, type: 'user', text: 'history item' }], pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); unmount(); }); it('should render dialog manager when dialogs are visible', async () => { const dialogUIState = { ...mockUIState, dialogsVisible: true, } as UIState; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('DialogManager'); unmount(); }); it.each([ { key: 'C', stateKey: 'ctrlCPressedOnce' }, { key: 'D', stateKey: 'ctrlDPressedOnce' }, ])( 'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true', async ({ key, stateKey }) => { const uiState = { ...mockUIState, dialogsVisible: true, [stateKey]: true, } as UIState; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); unmount(); }, ); it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Composer'); unmount(); }); it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); unmount(); }); it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); const toolCalls = [ { callId: 'call-1', name: 'ls', description: 'list directory', status: CoreToolCallStatus.AwaitingApproval, resultDisplay: '', confirmationDetails: { type: 'exec' as const, title: 'Confirm execution', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ]; const stateWithConfirmingTool = { ...mockUIState, pendingHistoryItems: [ { type: 'tool_group', tools: toolCalls, }, ], pendingGeminiHistoryItems: [ { type: 'tool_group', tools: toolCalls, }, ], } as UIState; const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: stateWithConfirmingTool, config: configWithExperiment, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue expect(lastFrame()).toContain('Composer'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with dialogs visible', async () => { const dialogUIState = { ...mockUIState, dialogsVisible: true, } as UIState; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); }); }); ================================================ FILE: packages/cli/src/ui/App.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useIsScreenReaderEnabled } from 'ink'; import { useUIState } from './contexts/UIStateContext.js'; import { StreamingContext } from './contexts/StreamingContext.js'; import { QuittingDisplay } from './components/QuittingDisplay.js'; import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js'; import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; export const App = () => { const uiState = useUIState(); const isAlternateBuffer = useAlternateBuffer(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); if (uiState.quittingMessages) { if (isAlternateBuffer) { return ( ); } else { return ; } } return ( {isScreenReaderEnabled ? : } ); }; ================================================ FILE: packages/cli/src/ui/AppContainer.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, type MockedObject, } from 'vitest'; import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; import { act, useContext, type ReactElement } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type TrackedToolCall } from './hooks/useToolScheduler.js'; import { type Config, makeFakeConfig, CoreEvent, type UserFeedbackPayload, type ResumedSessionData, type StartupWarning, WarningPriority, AuthType, type AgentDefinition, CoreToolCallStatus, } from '@google/gemini-cli-core'; // Mock coreEvents const mockCoreEvents = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), drainBacklogs: vi.fn(), emit: vi.fn(), })); // Mock IdeClient const mockIdeClient = vi.hoisted(() => ({ getInstance: vi.fn().mockReturnValue(new Promise(() => {})), })); // Mock stdout const mocks = vi.hoisted(() => ({ mockStdout: { write: vi.fn() }, })); const terminalNotificationsMocks = vi.hoisted(() => ({ notifyViaTerminal: vi.fn().mockResolvedValue(true), isNotificationsEnabled: vi.fn(() => true), buildRunEventNotificationContent: vi.fn((event) => ({ title: 'Mock Notification', subtitle: 'Mock Subtitle', body: JSON.stringify(event), })), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: mockCoreEvents, IdeClient: mockIdeClient, writeToStdout: vi.fn((...args) => process.stdout.write( ...(args as Parameters), ), ), writeToStderr: vi.fn((...args) => process.stderr.write( ...(args as Parameters), ), ), patchStdio: vi.fn(() => () => {}), createWorkingStdio: vi.fn(() => ({ stdout: process.stdout, stderr: process.stderr, })), enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), FileDiscoveryService: vi.fn().mockImplementation(() => ({ initialize: vi.fn(), })), startupProfiler: { flush: vi.fn(), start: vi.fn(), end: vi.fn(), }, }; }); import ansiEscapes from 'ansi-escapes'; import { type LoadedSettings } from '../config/settings.js'; import { createMockSettings } from '../test-utils/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; import { KeypressProvider } from './contexts/KeypressContext.js'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { useOverflowActions, type OverflowActions, } from './contexts/OverflowContext.js'; // Mock useStdout to capture terminal title writes vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useStdout: () => ({ stdout: mocks.mockStdout }), measureElement: vi.fn(), }; }); // Helper component will read the context values provided by AppContainer // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; capturedOverflowActions = useOverflowActions()!; return null; } vi.mock('./App.js', () => ({ App: TestContextConsumer, })); vi.mock('./hooks/useQuotaAndFallback.js'); vi.mock('./hooks/useHistoryManager.js'); vi.mock('./hooks/useThemeCommand.js'); vi.mock('./auth/useAuth.js'); vi.mock('./hooks/useEditorSettings.js'); vi.mock('./hooks/useSettingsCommand.js'); vi.mock('./hooks/useModelCommand.js'); vi.mock('./hooks/slashCommandProcessor.js'); vi.mock('./hooks/useConsoleMessages.js'); vi.mock('./hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), })); vi.mock('./hooks/useGeminiStream.js'); vi.mock('./hooks/vim.js'); vi.mock('./hooks/useFocus.js'); vi.mock('./hooks/useBracketedPaste.js'); vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useSuspend.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useApprovalModeIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./hooks/useExtensionUpdates.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); vi.mock('./hooks/useInputHistoryStore.js'); vi.mock('./hooks/atCommandProcessor.js'); vi.mock('./hooks/useHookDisplayState.js'); vi.mock('./hooks/useBanner.js', () => ({ useBanner: vi.fn((bannerData) => ({ bannerText: ( bannerData.warningText || bannerData.defaultText || '' ).replace(/\\n/g, '\n'), })), })); vi.mock('./hooks/useShellInactivityStatus.js', () => ({ useShellInactivityStatus: vi.fn(() => ({ shouldShowFocusHint: false, inactivityStatus: 'none', })), })); vi.mock('../utils/terminalNotifications.js', () => ({ notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, buildRunEventNotificationContent: terminalNotificationsMocks.buildRunEventNotificationContent, })); vi.mock('./hooks/useTerminalTheme.js', () => ({ useTerminalTheme: vi.fn(), })); import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFocus } from './hooks/useFocus.js'; // Mock external utilities vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); vi.mock('./utils/ConsolePatcher.js'); vi.mock('../utils/cleanup.js'); import { useHistory } from './hooks/useHistoryManager.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useConfirmUpdateRequests, useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js'; import { useSuspend } from './hooks/useSuspend.js'; import { writeToStdout, enableMouseEvents, disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; import { WARNING_PROMPT_DURATION_MS, EXPAND_HINT_DURATION_MS, } from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; // Helper to generate the AppContainer JSX for render and rerender const getAppContainer = ({ settings = mockSettings, config = mockConfig, version = '1.0.0', initResult = mockInitResult, startupWarnings, resumedSessionData, }: { settings?: LoadedSettings; config?: Config; version?: string; initResult?: InitializationResult; startupWarnings?: StartupWarning[]; resumedSessionData?: ResumedSessionData; } = {}) => ( ); // Helper to render the AppContainer const renderAppContainer = (props?: Parameters[0]) => render(getAppContainer(props)); // Create typed mocks for all hooks const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; const mockedUseHistory = useHistory as Mock; const mockedUseThemeCommand = useThemeCommand as Mock; const mockedUseAuthCommand = useAuthCommand as Mock; const mockedUseEditorSettings = useEditorSettings as Mock; const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; const mockedUseConsoleMessages = useErrorCount as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; const mockedUseIdeTrustListener = useIdeTrustListener as Mock; const mockedUseMessageQueue = useMessageQueue as Mock; const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; const mockedUseGitBranchName = useGitBranchName as Mock; const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock; const mockedUseExtensionUpdates = useExtensionUpdates as Mock; const mockedUseVimMode = useVimMode as Mock; const mockedUseSessionStats = useSessionStats as Mock; const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseLogger = useLogger as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock; const mockedUseSuspend = useSuspend as Mock; const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; const mockedUseFocusState = useFocus as Mock; const DEFAULT_GEMINI_STREAM_MOCK = { streamingState: 'idle', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), handleApprovalModeChange: vi.fn(), activePtyId: null, loopDetectionConfirmationRequest: null, backgroundShellCount: 0, isBackgroundShellVisible: false, toggleBackgroundShell: vi.fn(), backgroundCurrentShell: vi.fn(), backgroundShells: new Map(), registerBackgroundShell: vi.fn(), dismissBackgroundShell: vi.fn(), }; beforeEach(() => { persistentStateMock.reset(); vi.clearAllMocks(); mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); // Initialize mock stdout for terminal title tests mocks.mockStdout.write.mockClear(); capturedUIState = null!; // **Provide a default return value for EVERY mocked hook.** mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: null, handleProQuotaChoice: vi.fn(), }); mockedUseHistory.mockReturnValue({ history: [], addItem: vi.fn(), updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), }); mockedUseThemeCommand.mockReturnValue({ isThemeDialogOpen: false, openThemeDialog: vi.fn(), handleThemeSelect: vi.fn(), handleThemeHighlight: vi.fn(), }); mockedUseAuthCommand.mockReturnValue({ authState: 'authenticated', setAuthState: vi.fn(), authError: null, onAuthError: vi.fn(), }); mockedUseEditorSettings.mockReturnValue({ isEditorDialogOpen: false, openEditorDialog: vi.fn(), handleEditorSelect: vi.fn(), exitEditorDialog: vi.fn(), }); mockedUseSettingsCommand.mockReturnValue({ isSettingsDialogOpen: false, openSettingsDialog: vi.fn(), closeSettingsDialog: vi.fn(), }); mockedUseModelCommand.mockReturnValue({ isModelDialogOpen: false, openModelDialog: vi.fn(), closeModelDialog: vi.fn(), }); mockedUseSlashCommandProcessor.mockReturnValue({ handleSlashCommand: vi.fn(), slashCommands: [], pendingHistoryItems: [], commandContext: {}, shellConfirmationRequest: null, confirmationRequest: null, }); mockedUseConsoleMessages.mockReturnValue({ errorCount: 0, handleNewMessage: vi.fn(), clearErrorCount: vi.fn(), }); mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); mockedUseFolderTrust.mockReturnValue({ isFolderTrustDialogOpen: false, handleFolderTrustSelect: vi.fn(), isRestarting: false, }); mockedUseIdeTrustListener.mockReturnValue({ needsRestart: false, restartReason: 'NONE', }); mockedUseMessageQueue.mockReturnValue({ messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), }); mockedUseApprovalModeIndicator.mockReturnValue(false); mockedUseGitBranchName.mockReturnValue('main'); mockedUseVimMode.mockReturnValue({ isVimEnabled: false, toggleVimEnabled: vi.fn(), }); mockedUseSessionStats.mockReturnValue({ stats: {} }); mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), lines: [''], cursor: [0, 0], handleInput: vi.fn().mockReturnValue(false), }); mockedUseLogger.mockReturnValue({ getPreviousUserMessages: vi.fn().mockResolvedValue([]), }); mockedUseInputHistoryStore.mockReturnValue({ inputHistory: [], addInput: vi.fn(), initializeFromLogger: vi.fn(), }); mockedUseLoadingIndicator.mockReturnValue({ elapsedTime: '0.0s', currentLoadingPhrase: '', }); mockedUseSuspend.mockReturnValue({ handleSuspend: vi.fn(), }); mockedUseHookDisplayState.mockReturnValue([]); mockedUseTerminalTheme.mockReturnValue(undefined); mockedUseShellInactivityStatus.mockReturnValue({ shouldShowFocusHint: false, inactivityStatus: 'none', }); mockedUseFocusState.mockReturnValue({ isFocused: true, hasReceivedFocusEvent: true, }); mockedUseConfirmUpdateRequests.mockReturnValue({ addConfirmUpdateExtensionRequest: vi.fn(), confirmUpdateExtensionRequests: [], }); mockedUseExtensionUpdates.mockReturnValue({ extensionsUpdateState: new Map(), extensionsUpdateStateInternal: new Map(), dispatchExtensionStateUpdate: vi.fn(), }); // Mock Config mockConfig = makeFakeConfig(); // Mock config's getTargetDir to return consistent workspace directory vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false); mockExtensionManager = vi.mockObject({ getExtensions: vi.fn().mockReturnValue([]), setRequestConsent: vi.fn(), setRequestSetting: vi.fn(), start: vi.fn(), } as unknown as ExtensionManager); vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( mockExtensionManager, ); // Mock LoadedSettings mockSettings = createMockSettings({ hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', ui: { showStatusInTitle: false, hideWindowTitle: false, useAlternateBuffer: false, }, }); // Mock InitializationResult mockInitResult = { themeError: null, authError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, } as InitializationResult; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('renders with startup warnings', async () => { const startupWarnings: StartupWarning[] = [ { id: 'w1', message: 'Warning 1', priority: WarningPriority.High, }, { id: 'w2', message: 'Warning 2', priority: WarningPriority.High, }, ]; let unmount: () => void; await act(async () => { const result = renderAppContainer({ startupWarnings }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('shows full UI details by default', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState.cleanUiDetailsVisible).toBe(true); }); unmount!(); }); it('starts in minimal UI mode when Focus UI preference is persisted', async () => { persistentStateMock.get.mockReturnValueOnce(true); let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: mockSettings, }); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState.cleanUiDetailsVisible).toBe(false); }); expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); unmount!(); }); }); describe('State Initialization', () => { it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => { mockedUseFocusState.mockReturnValue({ isFocused: false, hasReceivedFocusEvent: true, }); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems: [ { type: 'tool_group', tools: [ { callId: 'call-1', name: 'run_shell_command', description: 'Run command', resultDisplay: undefined, status: CoreToolCallStatus.AwaitingApproval, confirmationDetails: { type: 'exec', title: 'Run shell command', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ], }, ], }); let unmount: (() => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; }); await waitFor(() => expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), ); expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( expect.objectContaining({ type: 'attention', }), ); await act(async () => { unmount?.(); }); }); it('does not send attention notification when terminal is focused', async () => { mockedUseFocusState.mockReturnValue({ isFocused: true, hasReceivedFocusEvent: true, }); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems: [ { type: 'tool_group', tools: [ { callId: 'call-2', name: 'run_shell_command', description: 'Run command', resultDisplay: undefined, status: CoreToolCallStatus.AwaitingApproval, confirmationDetails: { type: 'exec', title: 'Run shell command', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ], }, ], }); let unmount: (() => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); await act(async () => { unmount?.(); }); }); it('sends attention notification when focus reporting is unavailable', async () => { mockedUseFocusState.mockReturnValue({ isFocused: true, hasReceivedFocusEvent: false, }); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems: [ { type: 'tool_group', tools: [ { callId: 'call-focus-unknown', name: 'run_shell_command', description: 'Run command', resultDisplay: undefined, status: CoreToolCallStatus.AwaitingApproval, confirmationDetails: { type: 'exec', title: 'Run shell command', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ], }, ], }); let unmount: (() => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; }); await waitFor(() => expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), ); await act(async () => { unmount?.(); }); }); it('sends a macOS notification when a response completes while unfocused', async () => { mockedUseFocusState.mockReturnValue({ isFocused: false, hasReceivedFocusEvent: true, }); let currentStreamingState: 'idle' | 'responding' = 'responding'; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: currentStreamingState, })); let unmount: (() => void) | undefined; let rerender: ((tree: ReactElement) => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; rerender = rendered.rerender; }); currentStreamingState = 'idle'; await act(async () => { rerender?.(getAppContainer()); }); await waitFor(() => expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( expect.objectContaining({ type: 'session_complete', detail: 'Gemini CLI finished responding.', }), ), ); expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); await act(async () => { unmount?.(); }); }); it('sends completion notification when focus reporting is unavailable', async () => { mockedUseFocusState.mockReturnValue({ isFocused: true, hasReceivedFocusEvent: false, }); let currentStreamingState: 'idle' | 'responding' = 'responding'; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: currentStreamingState, })); let unmount: (() => void) | undefined; let rerender: ((tree: ReactElement) => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; rerender = rendered.rerender; }); currentStreamingState = 'idle'; await act(async () => { rerender?.(getAppContainer()); }); await waitFor(() => expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( expect.objectContaining({ type: 'session_complete', detail: 'Gemini CLI finished responding.', }), ), ); await act(async () => { unmount?.(); }); }); it('does not send completion notification when another action-required dialog is pending', async () => { mockedUseFocusState.mockReturnValue({ isFocused: false, hasReceivedFocusEvent: true, }); mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: { kind: 'upgrade' }, handleProQuotaChoice: vi.fn(), }); let currentStreamingState: 'idle' | 'responding' = 'responding'; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: currentStreamingState, })); let unmount: (() => void) | undefined; let rerender: ((tree: ReactElement) => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; rerender = rendered.rerender; }); currentStreamingState = 'idle'; await act(async () => { rerender?.(getAppContainer()); }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); await act(async () => { unmount?.(); }); }); it('can send repeated attention notifications for the same key after pending state clears', async () => { mockedUseFocusState.mockReturnValue({ isFocused: false, hasReceivedFocusEvent: true, }); let pendingHistoryItems = [ { type: 'tool_group', tools: [ { callId: 'repeat-key-call', name: 'run_shell_command', description: 'Run command', resultDisplay: undefined, status: CoreToolCallStatus.AwaitingApproval, confirmationDetails: { type: 'exec', title: 'Run shell command', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ], }, ]; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems, })); let unmount: (() => void) | undefined; let rerender: ((tree: ReactElement) => void) | undefined; await act(async () => { const rendered = renderAppContainer(); unmount = rendered.unmount; rerender = rendered.rerender; }); await waitFor(() => expect( terminalNotificationsMocks.notifyViaTerminal, ).toHaveBeenCalledTimes(1), ); pendingHistoryItems = []; await act(async () => { rerender?.(getAppContainer()); }); pendingHistoryItems = [ { type: 'tool_group', tools: [ { callId: 'repeat-key-call', name: 'run_shell_command', description: 'Run command', resultDisplay: undefined, status: CoreToolCallStatus.AwaitingApproval, confirmationDetails: { type: 'exec', title: 'Run shell command', command: 'ls', rootCommand: 'ls', rootCommands: ['ls'], }, }, ], }, ]; await act(async () => { rerender?.(getAppContainer()); }); await waitFor(() => expect( terminalNotificationsMocks.notifyViaTerminal, ).toHaveBeenCalledTimes(2), ); await act(async () => { unmount?.(); }); }); it('initializes with theme error from initialization result', async () => { const initResultWithError = { ...mockInitResult, themeError: 'Failed to load theme', }; let unmount: () => void; await act(async () => { const result = renderAppContainer({ initResult: initResultWithError, }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('handles debug mode state', () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); expect(() => { renderAppContainer({ config: debugConfig }); }).not.toThrow(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer({ version: '2.0.0' }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Should render and unmount cleanly expect(() => unmount!()).not.toThrow(); }); it('provides UIStateContext with state management', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('provides UIActionsContext with action handlers', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('provides ConfigContext with config object', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); }); describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { const settingsAllHidden = createMockSettings({ hideBanner: true, hideFooter: true, hideTips: true, showMemoryUsage: false, }); let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: settingsAllHidden }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); it('handles settings with memory usage enabled', async () => { const settingsWithMemory = createMockSettings({ showMemoryUsage: true, }); let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: settingsWithMemory }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); }); describe('Version Handling', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { let unmount: () => void; await act(async () => { const result = renderAppContainer({ version }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }, ); }); describe('Error Handling', () => { it('handles config methods that might throw', async () => { const errorConfig = makeFakeConfig(); vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { throw new Error('Config error'); }); // Should still render without crashing - errors should be handled internally const { unmount } = renderAppContainer({ config: errorConfig }); unmount(); }); it('handles undefined settings gracefully', async () => { const undefinedSettings = createMockSettings(); let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: undefinedSettings }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); }); describe('Provider Hierarchy', () => { it('establishes correct provider nesting order', () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies const { unmount } = renderAppContainer(); expect(() => unmount()).not.toThrow(); }); }); describe('Session Resumption', () => { it('handles resumed session data correctly', async () => { const mockResumedSessionData = { conversation: { sessionId: 'test-session-123', projectHash: 'test-project-hash', startTime: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-01T00:00:01Z', messages: [ { id: 'msg-1', type: 'user' as const, content: 'Hello', timestamp: '2024-01-01T00:00:00Z', }, { id: 'msg-2', type: 'gemini' as const, content: 'Hi there!', role: 'model' as const, parts: [{ text: 'Hi there!' }], timestamp: '2024-01-01T00:00:01Z', }, ], }, filePath: '/tmp/test-session.json', }; let unmount: () => void; await act(async () => { const result = renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: mockResumedSessionData, }); unmount = result.unmount; }); await act(async () => { unmount(); }); }); it('renders without resumed session data', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: undefined, }); unmount = result.unmount; }); await act(async () => { unmount(); }); }); it('initializes chat recording service when config has it', () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), }; const mockGeminiClient = { isInitialized: vi.fn(() => true), resumeChat: vi.fn(), getUserTier: vi.fn(), getChatRecordingService: vi.fn(() => mockChatRecordingService), }; const configWithRecording = makeFakeConfig(); vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); expect(() => { renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, }); }).not.toThrow(); }); }); describe('Session Recording Integration', () => { it('provides chat recording service configuration', () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), getSessionId: vi.fn(() => 'test-session-123'), getCurrentConversation: vi.fn(), }; const mockGeminiClient = { isInitialized: vi.fn(() => true), resumeChat: vi.fn(), getUserTier: vi.fn(), getChatRecordingService: vi.fn(() => mockChatRecordingService), setHistory: vi.fn(), }; const configWithRecording = makeFakeConfig(); vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue( 'test-session-123', ); expect(() => { renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, }); }).not.toThrow(); // Verify the recording service structure is correct expect(configWithRecording.getGeminiClient).toBeDefined(); expect(mockGeminiClient.getChatRecordingService).toBeDefined(); expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); }); it('handles session recording when messages are added', () => { const mockRecordMessage = vi.fn(); const mockRecordMessageTokens = vi.fn(); const mockChatRecordingService = { initialize: vi.fn(), recordMessage: mockRecordMessage, recordMessageTokens: mockRecordMessageTokens, recordToolCalls: vi.fn(), getSessionId: vi.fn(() => 'test-session-123'), }; const mockGeminiClient = { isInitialized: vi.fn(() => true), getChatRecordingService: vi.fn(() => mockChatRecordingService), getUserTier: vi.fn(), }; const configWithRecording = makeFakeConfig(); vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, }); // The actual recording happens through the useHistory hook // which would be triggered by user interactions expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); }); }); describe('Session Resume Flow', () => { it('accepts resumed session data', () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => true), resumeChat: mockResumeChat, getUserTier: vi.fn(), getChatRecordingService: vi.fn(() => ({ initialize: vi.fn(), recordMessage: vi.fn(), recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), })), }; const configWithClient = makeFakeConfig(); vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); const resumedData = { conversation: { sessionId: 'resumed-session-456', projectHash: 'project-hash', startTime: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-01T00:01:00Z', messages: [ { id: 'msg-1', type: 'user' as const, content: 'Previous question', timestamp: '2024-01-01T00:00:00Z', }, { id: 'msg-2', type: 'gemini' as const, content: 'Previous answer', role: 'model' as const, parts: [{ text: 'Previous answer' }], timestamp: '2024-01-01T00:00:30Z', tokenCount: { input: 10, output: 20 }, }, ], }, filePath: '/tmp/resumed-session.json', }; expect(() => { renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, }); }).not.toThrow(); // Verify the resume functionality structure is in place expect(mockGeminiClient.resumeChat).toBeDefined(); expect(resumedData.conversation.messages).toHaveLength(2); }); it('does not attempt resume when client is not initialized', () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => false), // Not initialized resumeChat: mockResumeChat, getUserTier: vi.fn(), getChatRecordingService: vi.fn(), }; const configWithClient = makeFakeConfig(); vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); const resumedData = { conversation: { sessionId: 'test-session', projectHash: 'project-hash', startTime: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-01T00:01:00Z', messages: [], }, filePath: '/tmp/session.json', }; renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, }); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); }); }); describe('Token Counting from Session Stats', () => { it('tracks token counts from session messages', () => { // Session stats are provided through the SessionStatsProvider context // in the real app, not through the config directly const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), getSessionId: vi.fn(() => 'test-session-123'), getCurrentConversation: vi.fn(() => ({ sessionId: 'test-session-123', messages: [], totalInputTokens: 150, totalOutputTokens: 350, })), }; const mockGeminiClient = { isInitialized: vi.fn(() => true), getChatRecordingService: vi.fn(() => mockChatRecordingService), getUserTier: vi.fn(), }; const configWithRecording = makeFakeConfig(); vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( mockGeminiClient as unknown as ReturnType, ); renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, }); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { // Assert that the context value is as expected expect(capturedUIState.quota.proQuotaRequest).toBeNull(); }); unmount!(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { // Arrange: Create a mock request object that a UI dialog would receive const mockRequest = { failedModel: 'gemini-pro', fallbackModel: 'gemini-flash', resolve: vi.fn(), }; mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: mockRequest, handleProQuotaChoice: vi.fn(), }); // Act: Render the container let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { // Assert: The mock request is correctly passed through the context expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); }); unmount!(); }); it('passes the handleProQuotaChoice function to UIActionsContext', async () => { // Arrange: Create a mock handler function const mockHandler = vi.fn(); mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: null, handleProQuotaChoice: mockHandler, }); // Act: Render the container let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { // Assert: The action in the context is the mock handler we provided expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); }); // You can even verify that the plumbed function is callable act(() => { capturedUIActions.handleProQuotaChoice('retry_later'); }); expect(mockHandler).toHaveBeenCalledWith('retry_later'); unmount!(); }); }); describe('Terminal Title Update Feature', () => { beforeEach(() => { // Reset mock stdout for each test mocks.mockStdout.write.mockClear(); }); it('verifies useStdout is mocked', async () => { const { useStdout } = await import('ink'); const { stdout } = useStdout(); expect(stdout).toBe(mocks.mockStdout); }); it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled const mockSettingsWithShowStatusFalse = createMockSettings({ ui: { showStatusInTitle: false, hideWindowTitle: false, }, }); // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Some thought' }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithShowStatusFalse, }); // Assert: Check that title was updated with "Working…" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${'✦ Working… (workspace)'.padEnd(80, ' ')}\x07`, ); unmount(); }); it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled const mockSettingsWithDynamicTitleFalse = createMockSettings({ ui: { dynamicWindowTitle: false, hideWindowTitle: false, }, }); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Some thought' }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithDynamicTitleFalse, }); // Assert: Check that legacy title was used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`, ); unmount(); }); it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled const mockSettingsWithHideTitleTrue = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: true, }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithHideTitleTrue, }); // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(0); unmount(); }); it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock the streaming state and thought const thoughtSubject = 'Processing request'; mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: thoughtSubject }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, ); unmount(); }); it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`, ); unmount(); }); it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'waiting_for_confirmation', thought: { subject: thoughtSubject }, }); // Act: Render the container let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); unmount = result.unmount; }); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); unmount!(); }); describe('Shell Focus Action Required', () => { beforeEach(async () => { vi.useFakeTimers(); // Use real implementation for these tests to verify title updates const actual = await vi.importActual< typeof import('./hooks/useShellInactivityStatus.js') >('./hooks/useShellInactivityStatus.js'); mockedUseShellInactivityStatus.mockImplementation( actual.useShellInactivityStatus, ); }); afterEach(() => { vi.useRealTimers(); }); it('should show Action Required in title after a delay when shell is awaiting focus', async () => { const startTime = 1000000; vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Executing shell command' }, pendingToolCalls: [], activePtyId: 'pty-1', lastOutputTime: startTime + 100, // Trigger aggressive delay retryStatus: null, }); vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container (embeddedShellFocused is false by default in state) const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Initially it should show the working status const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites[titleWrites.length - 1][0]).toContain( '✦ Executing shell command', ); // Fast-forward time by 40 seconds await act(async () => { await vi.advanceTimersByTimeAsync(40000); }); // Now it should show Action Required const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( (call) => call[0].includes('\x1b]0;'), ); const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; expect(lastTitle).toContain('✋ Action Required'); unmount(); }); it('should show Working… in title for redirected commands after 2 mins', async () => { const startTime = 1000000; vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Executing shell command' }, pendingToolCalls: [ { request: { name: 'run_shell_command', args: { command: 'ls > out' }, }, status: CoreToolCallStatus.Executing, } as unknown as TrackedToolCall, ], activePtyId: 'pty-1', lastOutputTime: startTime, retryStatus: null, }); vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Fast-forward time by 65 seconds - should still NOT be Action Required await act(async () => { await vi.advanceTimersByTimeAsync(65000); }); const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( (call) => call[0].includes('\x1b]0;'), ); expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( '✋ Action Required', ); // Fast-forward to 2 minutes (120000ms) await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( (call) => call[0].includes('\x1b]0;'), ); expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( '⏲ Working…', ); unmount(); }); it('should show Working… in title for silent non-redirected commands after 1 min', async () => { const startTime = 1000000; vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Executing shell command' }, pendingToolCalls: [], activePtyId: 'pty-1', lastOutputTime: startTime, // lastOutputTime <= operationStartTime retryStatus: null, }); vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Fast-forward time by 65 seconds await act(async () => { await vi.advanceTimersByTimeAsync(65000); }); const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); const lastTitle = titleWrites[titleWrites.length - 1][0]; // Should show Working… (⏲) instead of Action Required (✋) expect(lastTitle).toContain('⏲ Working…'); unmount(); }); it('should NOT show Action Required in title if shell is streaming output', async () => { const startTime = 1000000; vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Executing shell command' }, activePtyId: 'pty-1', lastOutputTime, })); vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container const { unmount, rerender } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Fast-forward time by 20 seconds await act(async () => { await vi.advanceTimersByTimeAsync(20000); }); // Update lastOutputTime to simulate new output lastOutputTime = startTime + 21000; mockedUseGeminiStream.mockImplementation(() => ({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: 'Executing shell command' }, activePtyId: 'pty-1', lastOutputTime, })); // Rerender to propagate the new lastOutputTime await act(async () => { rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); }); // Fast-forward time by another 20 seconds // Total time elapsed: 40s. // Time since last output: 20s. // It should NOT show Action Required yet. await act(async () => { await vi.advanceTimersByTimeAsync(20000); }); const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( (call) => call[0].includes('\x1b]0;'), ); const lastTitle = titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; expect(lastTitle).not.toContain('✋ Action Required'); expect(lastTitle).toContain('✦ Executing shell command'); // Fast-forward another 40 seconds (Total 60s since last output) await act(async () => { await vi.advanceTimersByTimeAsync(40000); }); // Now it SHOULD show Action Required const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; expect(lastTitleFinal).toContain('✋ Action Required'); unmount(); }); }); it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: shortTitle }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); const calledWith = titleWrites[0][0]; const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' '); const expectedEscapeSequence = `\x1b]0;${expectedTitle}\x07`; expect(calledWith).toBe(expectedEscapeSequence); unmount(); }); it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { showStatusInTitle: true, hideWindowTitle: false, }, }); // Mock the streaming state and thought const title = 'Test Title'; mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', thought: { subject: title }, }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, }); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); const expectedEscapeSequence = `\x1b]0;${`✦ ${title} (workspace)`.padEnd(80, ' ')}\x07`; expect(titleWrites[0][0]).toBe(expectedEscapeSequence); unmount(); }); it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) const mockSettingsWithTitleDisabled = createMockSettings({ ui: { showStatusInTitle: false, hideWindowTitle: false, }, }); // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', }); // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleDisabled, }); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( `\x1b]0;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, ); unmount(); }); }); describe('Queue Error Message', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('should set and clear the queue error message after a timeout', async () => { const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); expect(capturedUIState.queueErrorMessage).toBeNull(); act(() => { capturedUIActions.setQueueErrorMessage('Test error'); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Test error'); act(() => { vi.advanceTimersByTime(3000); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBeNull(); unmount(); }); it('should reset the timer if a new error message is set', async () => { const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); act(() => { capturedUIActions.setQueueErrorMessage('First error'); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('First error'); act(() => { vi.advanceTimersByTime(1500); }); act(() => { capturedUIActions.setQueueErrorMessage('Second error'); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Second error'); act(() => { vi.advanceTimersByTime(2000); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBe('Second error'); // 5. Advance time past the 3 second timeout from the second message act(() => { vi.advanceTimersByTime(1000); }); rerender(getAppContainer()); expect(capturedUIState.queueErrorMessage).toBeNull(); unmount(); }); }); describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; let stdin: ReturnType['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { const renderResult = renderAppContainer(); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); }); rerender = () => { renderResult.rerender(getAppContainer()); }; unmount = renderResult.unmount; }; const pressKey = (sequence: string, times = 1) => { for (let i = 0; i < times; i++) { act(() => { stdin.write(sequence); }); rerender(); } }; beforeEach(() => { // Mock slash command handler mockHandleSlashCommand = vi.fn(); mockedUseSlashCommandProcessor.mockReturnValue({ handleSlashCommand: mockHandleSlashCommand, slashCommands: [], pendingHistoryItems: [], commandContext: {}, shellConfirmationRequest: null, confirmationRequest: null, }); // Mock request cancellation mockCancelOngoingRequest = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, cancelOngoingRequest: mockCancelOngoingRequest, }); // Default empty text buffer mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), lines: [''], cursor: [0, 0], handleInput: vi.fn().mockReturnValue(false), }); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('CTRL+C', () => { it('should cancel ongoing request on first press', async () => { mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', cancelOngoingRequest: mockCancelOngoingRequest, }); await setupKeypressTest(); pressKey('\x03'); // Ctrl+C expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); unmount(); }); it('should quit on second press', async () => { await setupKeypressTest(); pressKey('\x03', 2); // Ctrl+C expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/quit', undefined, undefined, false, ); unmount(); }); it('should reset press count after a timeout', async () => { await setupKeypressTest(); pressKey('\x03'); // Ctrl+C expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold act(() => { vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey('\x03'); // Ctrl+C expect(mockHandleSlashCommand).not.toHaveBeenCalled(); unmount(); }); }); describe('CTRL+D', () => { it('should quit on second press if buffer is empty', async () => { await setupKeypressTest(); pressKey('\x04', 2); // Ctrl+D expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/quit', undefined, undefined, false, ); unmount(); }); it('should NOT quit if buffer is not empty', async () => { mockedUseTextBuffer.mockReturnValue({ text: 'some text', setText: vi.fn(), lines: ['some text'], cursor: [0, 9], // At the end handleInput: vi.fn().mockReturnValue(false), }); await setupKeypressTest(); pressKey('\x04'); // Ctrl+D // Should only be called once, so count is 1, not quitting yet. expect(mockHandleSlashCommand).not.toHaveBeenCalled(); pressKey('\x04'); // Ctrl+D // Now count is 2, it should quit. expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/quit', undefined, undefined, false, ); unmount(); }); it('should reset press count after a timeout', async () => { await setupKeypressTest(); pressKey('\x04'); // Ctrl+D expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold act(() => { vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey('\x04'); // Ctrl+D expect(mockHandleSlashCommand).not.toHaveBeenCalled(); unmount(); }); }); describe('CTRL+Z', () => { it('should call handleSuspend', async () => { const handleSuspend = vi.fn(); mockedUseSuspend.mockReturnValue({ handleSuspend }); await setupKeypressTest(); pressKey('\x1A'); // Ctrl+Z expect(handleSuspend).toHaveBeenCalledTimes(1); unmount(); }); }); describe('Focus Handling (Tab / Shift+Tab)', () => { beforeEach(() => { // Mock activePtyId to enable focus mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: 1, }); }); it('should focus shell input on Tab', async () => { await setupKeypressTest(); pressKey('\t'); expect(capturedUIState.embeddedShellFocused).toBe(true); unmount(); }); it('should unfocus shell input on Shift+Tab', async () => { await setupKeypressTest(); // Focus first pressKey('\t'); expect(capturedUIState.embeddedShellFocused).toBe(true); // Unfocus via Shift+Tab pressKey('\x1b[Z'); expect(capturedUIState.embeddedShellFocused).toBe(false); unmount(); }); it('should auto-unfocus when activePtyId becomes null', async () => { // Start with active pty and focused mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: 1, }); const renderResult = render(getAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); // Focus it act(() => { renderResult.stdin.write('\t'); }); expect(capturedUIState.embeddedShellFocused).toBe(true); // Now mock activePtyId becoming null mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, }); // Rerender to trigger useEffect await act(async () => { renderResult.rerender(getAppContainer()); }); expect(capturedUIState.embeddedShellFocused).toBe(false); renderResult.unmount(); }); it('should focus background shell on Tab when already visible (not toggle it off)', async () => { const mockToggleBackgroundShell = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, isBackgroundShellVisible: true, backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), toggleBackgroundShell: mockToggleBackgroundShell, }); await setupKeypressTest(); // Initially not focused expect(capturedUIState.embeddedShellFocused).toBe(false); // Press Tab pressKey('\t'); // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); // Should NOT have toggled (closed) the shell expect(mockToggleBackgroundShell).not.toHaveBeenCalled(); unmount(); }); }); describe('Background Shell Toggling (CTRL+B)', () => { it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { const mockToggleBackgroundShell = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, isBackgroundShellVisible: true, backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), toggleBackgroundShell: mockToggleBackgroundShell, }); await setupKeypressTest(); // Initially not focused, but visible expect(capturedUIState.embeddedShellFocused).toBe(false); // Press Ctrl+B pressKey('\x02'); // Should have toggled (closed) the shell expect(mockToggleBackgroundShell).toHaveBeenCalled(); // Should be unfocused expect(capturedUIState.embeddedShellFocused).toBe(false); unmount(); }); it('should show and focus background shell on Ctrl+B if hidden', async () => { const mockToggleBackgroundShell = vi.fn(); const geminiStreamMock = { ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, isBackgroundShellVisible: false, backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), toggleBackgroundShell: mockToggleBackgroundShell, }; mockedUseGeminiStream.mockReturnValue(geminiStreamMock); await setupKeypressTest(); // Update the mock state when toggled to simulate real behavior mockToggleBackgroundShell.mockImplementation(() => { geminiStreamMock.isBackgroundShellVisible = true; }); // Press Ctrl+B pressKey('\x02'); // Should have toggled (shown) the shell expect(mockToggleBackgroundShell).toHaveBeenCalled(); // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); unmount(); }); }); }); describe('Shortcuts Help Visibility', () => { let handleGlobalKeypress: (key: Key) => boolean; let mockedUseKeypress: Mock; let rerender: () => void; let unmount: () => void; const setupShortcutsVisibilityTest = async () => { const renderResult = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); rerender = () => renderResult.rerender(getAppContainer()); unmount = renderResult.unmount; }; const pressKey = (key: Partial) => { act(() => { handleGlobalKeypress({ name: 'r', shift: false, alt: false, ctrl: false, cmd: false, insertable: false, sequence: '', ...key, } as Key); }); rerender(); }; beforeEach(() => { mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock; mockedUseKeypress.mockImplementation( (callback: (key: Key) => boolean, options: { isActive: boolean }) => { // AppContainer registers multiple keypress handlers; capture only // active handlers so inactive copy-mode handler doesn't override. if (options?.isActive) { handleGlobalKeypress = callback; } }, ); vi.useFakeTimers(); }); afterEach(() => { mockedUseKeypress.mockRestore(); vi.useRealTimers(); vi.restoreAllMocks(); }); it('dismisses shortcuts help when a registered hotkey is pressed', async () => { await setupShortcutsVisibilityTest(); act(() => { capturedUIActions.setShortcutsHelpVisible(true); }); rerender(); expect(capturedUIState.shortcutsHelpVisible).toBe(true); pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); // Ctrl+R expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); it('dismisses shortcuts help when streaming starts', async () => { await setupShortcutsVisibilityTest(); act(() => { capturedUIActions.setShortcutsHelpVisible(true); }); rerender(); expect(capturedUIState.shortcutsHelpVisible).toBe(true); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', }); await act(async () => { rerender(); }); await waitFor(() => { expect(capturedUIState.shortcutsHelpVisible).toBe(false); }); unmount(); }); it('dismisses shortcuts help when action-required confirmation appears', async () => { await setupShortcutsVisibilityTest(); act(() => { capturedUIActions.setShortcutsHelpVisible(true); }); rerender(); expect(capturedUIState.shortcutsHelpVisible).toBe(true); mockedUseSlashCommandProcessor.mockReturnValue({ handleSlashCommand: vi.fn(), slashCommands: [], pendingHistoryItems: [], commandContext: {}, shellConfirmationRequest: null, confirmationRequest: { prompt: 'Confirm this action?', onConfirm: vi.fn(), }, }); await act(async () => { rerender(); }); await waitFor(() => { expect(capturedUIState.shortcutsHelpVisible).toBe(false); }); unmount(); }); }); describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; let stdin: ReturnType['stdin']; const setupCopyModeTest = async ( isAlternateMode = false, childHandler?: Mock, ) => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( isAlternateMode, ); // Update settings for this test run const testSettings = createMockSettings({ ui: { useAlternateBuffer: isAlternateMode }, }); function TestChild() { useKeypress(childHandler || (() => {}), { isActive: !!childHandler, priority: true, }); return null; } const getTree = (settings: LoadedSettings) => ( ); const renderResult = render(getTree(testSettings)); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); }); rerender = () => renderResult.rerender(getTree(testSettings)); unmount = renderResult.unmount; }; beforeEach(() => { mocks.mockStdout.write.mockClear(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe.each([ { isAlternateMode: false, shouldEnable: false, modeName: 'Normal Mode', }, { isAlternateMode: true, shouldEnable: true, modeName: 'Alternate Buffer Mode', }, ])('$modeName', ({ isAlternateMode, shouldEnable }) => { it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => { await setupCopyModeTest(isAlternateMode); mocks.mockStdout.write.mockClear(); // Clear initial enable call act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); if (shouldEnable) { expect(disableMouseEvents).toHaveBeenCalled(); } else { expect(disableMouseEvents).not.toHaveBeenCalled(); } unmount(); }); if (shouldEnable) { it('should toggle mouse back on when Ctrl+S is pressed again', async () => { await setupCopyModeTest(isAlternateMode); (writeToStdout as Mock).mockClear(); // Turn it on (disable mouse) act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); expect(disableMouseEvents).toHaveBeenCalled(); // Turn it off (enable mouse) act(() => { stdin.write('a'); // Any key should exit copy mode }); rerender(); expect(enableMouseEvents).toHaveBeenCalled(); unmount(); }); it('should exit copy mode on non-scroll key press', async () => { await setupCopyModeTest(isAlternateMode); // Enter copy mode act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); (writeToStdout as Mock).mockClear(); // Press any other key act(() => { stdin.write('a'); }); rerender(); // Should have re-enabled mouse expect(enableMouseEvents).toHaveBeenCalled(); unmount(); }); it('should not exit copy mode on PageDown and should pass it through', async () => { const childHandler = vi.fn().mockReturnValue(false); await setupCopyModeTest(true, childHandler); // Enter copy mode act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); expect(disableMouseEvents).toHaveBeenCalled(); childHandler.mockClear(); (enableMouseEvents as Mock).mockClear(); // PageDown should be passed through to lower-priority handlers. act(() => { stdin.write('\x1b[6~'); }); rerender(); expect(enableMouseEvents).not.toHaveBeenCalled(); expect(childHandler).toHaveBeenCalled(); expect(childHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'pagedown' }), ); unmount(); }); it('should not exit copy mode on Shift+Down and should pass it through', async () => { const childHandler = vi.fn().mockReturnValue(false); await setupCopyModeTest(true, childHandler); // Enter copy mode act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); expect(disableMouseEvents).toHaveBeenCalled(); childHandler.mockClear(); (enableMouseEvents as Mock).mockClear(); act(() => { stdin.write('\x1b[1;2B'); // Shift+Down }); rerender(); expect(enableMouseEvents).not.toHaveBeenCalled(); expect(childHandler).toHaveBeenCalled(); expect(childHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'down', shift: true }), ); unmount(); }); it('should have higher priority than other priority listeners when enabled', async () => { // 1. Initial state with a child component's priority listener (already subscribed) // It should NOT handle Ctrl+S so we can enter copy mode. const childHandler = vi.fn().mockReturnValue(false); await setupCopyModeTest(true, childHandler); // 2. Enter copy mode act(() => { stdin.write('\x13'); // Ctrl+S }); rerender(); // 3. Verify we are in copy mode expect(disableMouseEvents).toHaveBeenCalled(); // 4. Press any key childHandler.mockClear(); // Now childHandler should return true for other keys, simulating a greedy listener childHandler.mockReturnValue(true); act(() => { stdin.write('a'); }); rerender(); // 5. Verify that the exit handler took priority and childHandler was NOT called expect(childHandler).not.toHaveBeenCalled(); expect(enableMouseEvents).toHaveBeenCalled(); unmount(); }); } }); }); describe('Model Dialog Integration', () => { it('should provide isModelDialogOpen in the UIStateContext', async () => { mockedUseModelCommand.mockReturnValue({ isModelDialogOpen: true, openModelDialog: vi.fn(), closeModelDialog: vi.fn(), }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(capturedUIState.isModelDialogOpen).toBe(true); unmount!(); }); it('should provide model dialog actions in the UIActionsContext', async () => { const mockCloseModelDialog = vi.fn(); mockedUseModelCommand.mockReturnValue({ isModelDialogOpen: false, openModelDialog: vi.fn(), closeModelDialog: mockCloseModelDialog, }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Verify that the actions are correctly passed through context act(() => { capturedUIActions.closeModelDialog(); }); expect(mockCloseModelDialog).toHaveBeenCalled(); unmount!(); }); }); describe('Agent Configuration Dialog Integration', () => { it('should initialize with dialog closed and no agent selected', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); unmount!(); }); it('should update state when openAgentConfigDialog is called', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); const agentDefinition = { name: 'test-agent' }; act(() => { capturedUIActions.openAgentConfigDialog( 'test-agent', 'Test Agent', agentDefinition as unknown as AgentDefinition, ); }); expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); expect(capturedUIState.selectedAgentName).toBe('test-agent'); expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); unmount!(); }); it('should clear state when closeAgentConfigDialog is called', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); const agentDefinition = { name: 'test-agent' }; act(() => { capturedUIActions.openAgentConfigDialog( 'test-agent', 'Test Agent', agentDefinition as unknown as AgentDefinition, ); }); expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); act(() => { capturedUIActions.closeAgentConfigDialog(); }); expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); unmount!(); }); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); unmount!(); }); it('unsubscribes from UserFeedback on unmount', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); expect(mockCoreEvents.off).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); }); it('adds history item when UserFeedback event is received', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( (call: unknown[]) => call[0] === CoreEvent.UserFeedback, )?.[1]; expect(handler).toBeDefined(); // Simulate an event const payload: UserFeedbackPayload = { severity: 'error', message: 'Test error message', }; act(() => { handler(payload); }); expect(mockedUseHistory().addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', text: 'Test error message', }), expect.any(Number), ); unmount!(); }); it('updates currentModel when ModelChanged event is received', async () => { // Arrange: Mock initial model vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState?.currentModel).toBe('initial-model'); }); // Get the registered handler for ModelChanged const handler = mockCoreEvents.on.mock.calls.find( (call: unknown[]) => call[0] === CoreEvent.ModelChanged, )?.[1]; expect(handler).toBeDefined(); // Act: Simulate ModelChanged event // Update config mock to return new model since the handler reads from config vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model'); act(() => { handler({ model: 'new-model' }); }); // Assert: Verify model is updated await waitFor(() => { expect(capturedUIState.currentModel).toBe('new-model'); }); unmount!(); }); it('provides activeHooks from useHookDisplayState', async () => { const mockHooks = [{ name: 'hook1', eventName: 'event1' }]; mockedUseHookDisplayState.mockReturnValue(mockHooks); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(capturedUIState.activeHooks).toEqual(mockHooks); unmount!(); }); it('handles consent request events', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); const handler = mockCoreEvents.on.mock.calls.find( (call: unknown[]) => call[0] === CoreEvent.ConsentRequest, )?.[1]; expect(handler).toBeDefined(); const onConfirm = vi.fn(); const payload = { prompt: 'Do you consent?', onConfirm, }; act(() => { handler(payload); }); expect(capturedUIState.authConsentRequest).toBeDefined(); expect(capturedUIState.authConsentRequest?.prompt).toBe( 'Do you consent?', ); act(() => { capturedUIState.authConsentRequest?.onConfirm(true); }); expect(onConfirm).toHaveBeenCalledWith(true); expect(capturedUIState.authConsentRequest).toBeNull(); unmount!(); }); it('unsubscribes from ConsentRequest on unmount', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); expect(mockCoreEvents.off).toHaveBeenCalledWith( CoreEvent.ConsentRequest, expect.any(Function), ); }); }); describe('Banner Text', () => { it('should render placeholder banner text for USE_GEMINI auth type', async () => { const config = makeFakeConfig(); vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({ authType: AuthType.USE_GEMINI, apiKey: 'fake-key', }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState.bannerData.defaultText).toBeDefined(); unmount!(); }); }); }); describe('onCancelSubmit Behavior', () => { let mockSetText: Mock; // Helper to extract arguments from the useGeminiStream hook call // This isolates the positional argument dependency to a single location const extractUseGeminiStreamArgs = (args: unknown[]) => ({ onCancelSubmit: args[13] as (shouldRestorePrompt?: boolean) => void, }); beforeEach(() => { mockSetText = vi.fn(); mockedUseTextBuffer.mockReturnValue({ text: '', setText: mockSetText, }); }); it('preserves buffer when cancelling, even if empty (user is in control)', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, ); act(() => { onCancelSubmit(false); }); // Should NOT modify buffer when cancelling - user is in control expect(mockSetText).not.toHaveBeenCalled(); unmount!(); }); it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { // Mock buffer with text that user typed while streaming (same as last message) const promptText = 'What is Python?'; mockedUseTextBuffer.mockReturnValue({ text: promptText, setText: mockSetText, }); // Mock input history with same message mockedUseInputHistoryStore.mockReturnValue({ inputHistory: [promptText], addInput: vi.fn(), initializeFromLogger: vi.fn(), }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, ); act(() => { // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) onCancelSubmit(false); }); // Should NOT call setText - prompt should be preserved regardless of content expect(mockSetText).not.toHaveBeenCalled(); unmount!(); }); it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { // Mock useInputHistoryStore to provide input history mockedUseInputHistoryStore.mockReturnValue({ inputHistory: ['previous message'], addInput: vi.fn(), initializeFromLogger: vi.fn(), }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState.userMessages).toContain('previous message'), ); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, ); await act(async () => { onCancelSubmit(true); }); await waitFor(() => { expect(mockSetText).toHaveBeenCalledWith('previous message'); }); unmount!(); }); it('input history is independent from conversation history (survives /clear)', async () => { // This test verifies that input history (used for up-arrow navigation) is maintained // separately from conversation history and survives /clear operations. const mockAddInput = vi.fn(); mockedUseInputHistoryStore.mockReturnValue({ inputHistory: ['first prompt', 'second prompt'], addInput: mockAddInput, initializeFromLogger: vi.fn(), }); let rerender: (tree: ReactElement) => void; let unmount; await act(async () => { const result = renderAppContainer(); rerender = result.rerender; unmount = result.unmount; }); // Verify userMessages is populated from inputHistory await waitFor(() => expect(capturedUIState.userMessages).toContain('first prompt'), ); expect(capturedUIState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) const mockClearItems = vi.fn(); mockedUseHistory.mockReturnValue({ history: [], addItem: vi.fn(), updateItem: vi.fn(), clearItems: mockClearItems, loadHistory: vi.fn(), }); await act(async () => { // Rerender to apply the new mock. rerender(getAppContainer()); }); // Verify that userMessages still contains the input history // (it should not be affected by clearing conversation history) expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); unmount!(); }); }); describe('Regression Tests', () => { it('does not refresh static on startup if banner text is empty', async () => { // Mock banner text to be empty strings vi.spyOn(mockConfig, 'getBannerTextNoCapacityIssues').mockResolvedValue( '', ); vi.spyOn(mockConfig, 'getBannerTextCapacityIssues').mockResolvedValue(''); // Clear previous calls mocks.mockStdout.write.mockClear(); let compUnmount: () => void = () => {}; await act(async () => { const { unmount } = renderAppContainer(); compUnmount = unmount; }); // Allow async effects to run await waitFor(() => expect(capturedUIState).toBeTruthy()); // Wait for fetchBannerTexts to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); // Check that clearTerminal was NOT written to stdout const clearTerminalCalls = mocks.mockStdout.write.mock.calls.filter( (call: unknown[]) => call[0] === ansiEscapes.clearTerminal, ); expect(clearTerminalCalls).toHaveLength(0); compUnmount(); }); }); describe('Submission Handling', () => { it('resets expansion state on submission when not in alternate buffer', async () => { const { checkPermissions } = await import( './hooks/atCommandProcessor.js' ); vi.mocked(checkPermissions).mockResolvedValue([]); let unmount: () => void; await act(async () => { unmount = renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }).unmount; }); await waitFor(() => expect(capturedUIActions).toBeTruthy()); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); expect(capturedUIState.constrainHeight).toBe(false); // Reset mock stdout to clear any initial writes mocks.mockStdout.write.mockClear(); // Submit await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); // Should be reset expect(capturedUIState.constrainHeight).toBe(true); // Should refresh static (which clears terminal in non-alternate buffer) expect(mocks.mockStdout.write).toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); unmount!(); }); it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { const { checkPermissions } = await import( './hooks/atCommandProcessor.js' ); vi.mocked(checkPermissions).mockResolvedValue([]); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); let unmount: () => void; await act(async () => { unmount = renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }).unmount; }); await waitFor(() => expect(capturedUIActions).toBeTruthy()); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); expect(capturedUIState.constrainHeight).toBe(false); // Reset mock stdout mocks.mockStdout.write.mockClear(); // Submit await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); // Should be reset expect(capturedUIState.constrainHeight).toBe(true); // Should NOT refresh static's clearTerminal in alternate buffer expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); unmount!(); }); }); describe('Overflow Hint Handling', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Trigger overflow act(() => { capturedOverflowActions.addOverflowingId('test-id'); }); await waitFor(() => { // Should show hint because we are in Standard Mode (default settings) and have overflow expect(capturedUIState.showIsExpandableHint).toBe(true); }); // Advance just before the timeout act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // Advance to hit the timeout mark act(() => { vi.advanceTimersByTime(100); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); unmount!(); }); it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // 1. Trigger first overflow act(() => { capturedOverflowActions.addOverflowingId('test-id-1'); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); // 2. Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // 3. Trigger second overflow (this should reset the timer) act(() => { capturedOverflowActions.addOverflowingId('test-id-2'); }); // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire // and flush the state update to AppContainer, triggering the reset. act(() => { vi.advanceTimersByTime(1); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); // 4. Advance enough that the ORIGINAL timer would have expired // Subtracting 1ms since we advanced it above to flush the state. act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); }); // The hint should STILL be visible because the timer reset at step 3 expect(capturedUIState.showIsExpandableHint).toBe(true); // 5. Advance to the end of the NEW timer act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); unmount!(); }); it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { let unmount: () => void; let stdin: ReturnType['stdin']; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; stdin = result.stdin; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); // Trigger overflow so the hint starts showing act(() => { capturedOverflowActions.addOverflowingId('test-id'); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); // Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // Simulate Ctrl+O act(() => { stdin.write('\x0f'); // \x0f is Ctrl+O }); await waitFor(() => { // constrainHeight should toggle expect(capturedUIState.constrainHeight).toBe(false); }); // Advance enough that the original timer would have expired if it hadn't reset act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); }); // We expect it to still be true because Ctrl+O should have reset the timer expect(capturedUIState.showIsExpandableHint).toBe(true); // Advance remaining time to reach the new timeout act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); unmount!(); }); it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { let unmount: () => void; let stdin: ReturnType['stdin']; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; stdin = result.stdin; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); // Trigger overflow so the hint starts showing act(() => { capturedOverflowActions.addOverflowingId('test-id'); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); // Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // First toggle 'on' (expanded) act(() => { stdin.write('\x0f'); // Ctrl+O }); await waitFor(() => { expect(capturedUIState.constrainHeight).toBe(false); }); // Wait 1 second act(() => { vi.advanceTimersByTime(1000); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // Second toggle 'off' (collapsed) act(() => { stdin.write('\x0f'); // Ctrl+O }); await waitFor(() => { expect(capturedUIState.constrainHeight).toBe(true); }); // Wait 1 second act(() => { vi.advanceTimersByTime(1000); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // Third toggle 'on' (expanded) act(() => { stdin.write('\x0f'); // Ctrl+O }); await waitFor(() => { expect(capturedUIState.constrainHeight).toBe(false); }); // Now we wait just before the timeout from the LAST toggle. // It should still be true. act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); }); expect(capturedUIState.showIsExpandableHint).toBe(true); // Wait 0.1s more to hit exactly the timeout since the last toggle. // It should hide now. act(() => { vi.advanceTimersByTime(100); }); await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); unmount!(); }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { const settingsWithAlternateBuffer = createMockSettings({ ui: { useAlternateBuffer: true }, }); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); let unmount: () => void; await act(async () => { const result = renderAppContainer({ settings: settingsWithAlternateBuffer, }); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); // Trigger overflow act(() => { capturedOverflowActions.addOverflowingId('test-id'); }); // Should NOW show hint because we are in Alternate Buffer Mode await waitFor(() => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); unmount!(); }); }); describe('Permission Handling', () => { it('shows permission dialog when checkPermissions returns paths', async () => { const { checkPermissions } = await import( './hooks/atCommandProcessor.js' ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); let unmount: () => void; await act(async () => (unmount = renderAppContainer().unmount)); await waitFor(() => expect(capturedUIActions).toBeTruthy()); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), ); expect(capturedUIState.permissionConfirmationRequest).not.toBeNull(); expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ '/test/file.txt', ]); await act(async () => unmount!()); }); it.each([true, false])( 'handles permissions when allowed is %s', async (allowed) => { const { checkPermissions } = await import( './hooks/atCommandProcessor.js' ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); const addReadOnlyPathSpy = vi.spyOn( mockConfig.getWorkspaceContext(), 'addReadOnlyPath', ); const { submitQuery } = mockedUseGeminiStream(); let unmount: () => void; await act(async () => (unmount = renderAppContainer().unmount)); await waitFor(() => expect(capturedUIActions).toBeTruthy()); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), ); await act(async () => capturedUIState.permissionConfirmationRequest?.onComplete({ allowed, }), ); if (allowed) { expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt'); } else { expect(addReadOnlyPathSpy).not.toHaveBeenCalled(); } expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); expect(capturedUIState.permissionConfirmationRequest).toBeNull(); await act(async () => unmount!()); }, ); }); describe('Plan Mode Availability', () => { it('should allow plan mode when enabled and idle', async () => { vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems: [], }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState).toBeTruthy(); expect(capturedUIState.allowPlanMode).toBe(true); }); unmount!(); }); it('should NOT allow plan mode when disabled in config', async () => { vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, pendingHistoryItems: [], }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState).toBeTruthy(); expect(capturedUIState.allowPlanMode).toBe(false); }); unmount!(); }); it('should NOT allow plan mode when streaming', async () => { vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: StreamingState.Responding, pendingHistoryItems: [], }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState).toBeTruthy(); expect(capturedUIState.allowPlanMode).toBe(false); }); unmount!(); }); it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: StreamingState.Idle, pendingHistoryItems: [ { type: 'tool_group', tools: [ { name: 'test_tool', status: CoreToolCallStatus.AwaitingApproval, }, ], }, ], }); let unmount: () => void; await act(async () => { const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { expect(capturedUIState).toBeTruthy(); expect(capturedUIState.allowPlanMode).toBe(false); }); unmount!(); }); }); }); ================================================ FILE: packages/cli/src/ui/AppContainer.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useMemo, useState, useCallback, useEffect, useRef, useLayoutEffect, } from 'react'; import { type DOMElement, measureElement, useApp, useStdout, useStdin, type AppProps, } from 'ink'; import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; import { ConfigContext } from './contexts/ConfigContext.js'; import { type HistoryItem, type HistoryItemWithoutId, type HistoryItemToolGroup, AuthState, type ConfirmationRequest, type PermissionConfirmationRequest, type QuotaStats, } from './types.js'; import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { type StartupWarning, type EditorType, type Config, type IdeInfo, type IdeContext, type UserTierId, type GeminiUserTier, type UserFeedbackPayload, type AgentDefinition, type ApprovalMode, IdeClient, ideContextStore, getErrorMessage, getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, type ResumedSessionData, recordExitFail, ShellExecutionService, saveApiKey, debugLogger, coreEvents, CoreEvent, refreshServerHierarchicalMemory, flattenMemory, type MemoryChangedPayload, writeToStdout, disableMouseEvents, enterAlternateScreen, enableMouseEvents, disableLineWrapping, shouldEnterAlternateScreen, startupProfiler, SessionStartSource, SessionEndReason, generateSummary, type ConsentRequestPayload, type AgentsDiscoveredPayload, ChangeAuthRequestedError, ProjectIdRequiredError, CoreToolCallStatus, buildUserSteeringHintPrompt, logBillingEvent, ApiKeyUpdatedEvent, type InjectionSource, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useOverflowActions, useOverflowState, } from './contexts/OverflowContext.js'; import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import { basename } from 'node:path'; import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { type BackgroundShell } from './hooks/shellCommandProcessor.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; import { Command } from './key/keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { relaunchApp } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useConfirmUpdateRequests, useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, EXPAND_HINT_DURATION_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { parseSlashCommand } from '../utils/commands.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { if (item && item.type === 'tool_group') { return item.tools.some( (tool) => CoreToolCallStatus.Executing === tool.status, ); } return false; }); } function isToolAwaitingConfirmation( pendingHistoryItems: HistoryItemWithoutId[], ) { return pendingHistoryItems .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .some((item) => item.tools.some( (tool) => CoreToolCallStatus.AwaitingApproval === tool.status, ), ); } interface AppContainerProps { config: Config; startupWarnings?: StartupWarning[]; version: string; initializationResult: InitializationResult; resumedSessionData?: ResumedSessionData; } import { useRepeatedKeyPress } from './hooks/useRepeatedKeyPress.js'; import { useVisibilityToggle, APPROVAL_MODE_REVEAL_DURATION_MS, } from './hooks/useVisibilityToggle.js'; import { useKeyMatchers } from './hooks/useKeyMatchers.js'; /** * The fraction of the terminal width to allocate to the shell. * This provides horizontal padding. */ const SHELL_WIDTH_FRACTION = 0.89; /** * The number of lines to subtract from the available terminal height * for the shell. This provides vertical padding and space for other UI elements. */ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const isHelpDismissKey = useIsHelpDismissKey(); const keyMatchers = useKeyMatchers(); const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); const historyManager = useHistory({ chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), }); useMemoryMonitor(historyManager); const isAlternateBuffer = config.getUseAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null >(null); const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); const [themeError, setThemeError] = useState( initializationResult.themeError, ); const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [showDebugProfiler, setShowDebugProfiler] = useState(false); const [customDialog, setCustomDialog] = useState( null, ); const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); const toggleBackgroundShellRef = useRef<() => void>(() => {}); const isBackgroundShellVisibleRef = useRef(false); const backgroundShellsRef = useRef>(new Map()); const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); const [shellModeActive, setShellModeActive] = useState(false); const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = useState(false); const [historyRemountKey, setHistoryRemountKey] = useState(0); const [settingsNonce, setSettingsNonce] = useState(0); const activeHooks = useHookDisplayState(); const [updateInfo, setUpdateInfo] = useState(null); const [isTrustedFolder, setIsTrustedFolder] = useState( () => isWorkspaceTrusted(settings.merged).isTrusted, ); const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage( QUEUE_ERROR_DISPLAY_DURATION_MS, ); const [newAgents, setNewAgents] = useState(null); const [constrainHeight, setConstrainHeight] = useState(true); const [expandHintTrigger, triggerExpandHint] = useTimedMessage( EXPAND_HINT_DURATION_MS, ); const showIsExpandableHint = Boolean(expandHintTrigger); const overflowState = useOverflowState(); const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; /** * Manages the visibility and x-second timer for the expansion hint. * * This effect triggers the timer countdown whenever an overflow is detected * or the user manually toggles the expansion state with Ctrl+O. * By depending on overflowingIdsSize, the timer resets when *new* views * overflow, but avoids infinitely resetting during single-view streaming. * * In alternate buffer mode, we don't trigger the hint automatically on overflow * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { if (hasOverflowState) { triggerExpandHint(true); } }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); const [bannerVisible, setBannerVisible] = useState(true); const bannerData = useMemo( () => ({ defaultText: defaultBannerText, warningText: warningBannerText, }), [defaultBannerText, warningBannerText], ); const { bannerText } = useBanner(bannerData); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const extensionManager = config.getExtensionLoader() as ExtensionManager; // We are in the interactive CLI, update how we request consent and settings. extensionManager.setRequestConsent((description) => requestConsentInteractive(description, addConfirmUpdateExtensionRequest), ); extensionManager.setRequestSetting(); const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = useConfirmUpdateRequests(); const { extensionsUpdateState, extensionsUpdateStateInternal, dispatchExtensionStateUpdate, } = useExtensionUpdates( extensionManager, historyManager.addItem, config.getEnableExtensionReloading(), ); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const [permissionsDialogProps, setPermissionsDialogProps] = useState<{ targetDirectory?: string; } | null>(null); const openPermissionsDialog = useCallback( (props?: { targetDirectory?: string }) => { setPermissionsDialogOpen(true); setPermissionsDialogProps(props ?? null); }, [], ); const closePermissionsDialog = useCallback(() => { setPermissionsDialogOpen(false); setPermissionsDialogProps(null); }, []); const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false); const [selectedAgentName, setSelectedAgentName] = useState< string | undefined >(); const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState< string | undefined >(); const [selectedAgentDefinition, setSelectedAgentDefinition] = useState< AgentDefinition | undefined >(); const openAgentConfigDialog = useCallback( (name: string, displayName: string, definition: AgentDefinition) => { setSelectedAgentName(name); setSelectedAgentDisplayName(displayName); setSelectedAgentDefinition(definition); setIsAgentConfigDialogOpen(true); }, [], ); const closeAgentConfigDialog = useCallback(() => { setIsAgentConfigDialogOpen(false); setSelectedAgentName(undefined); setSelectedAgentDisplayName(undefined); setSelectedAgentDefinition(undefined); }, []); const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], ); const [currentModel, setCurrentModel] = useState(config.getModel()); const [userTier, setUserTier] = useState(undefined); const [quotaStats, setQuotaStats] = useState(() => { const remaining = config.getQuotaRemaining(); const limit = config.getQuotaLimit(); const resetTime = config.getQuotaResetTime(); return remaining !== undefined || limit !== undefined || resetTime !== undefined ? { remaining, limit, resetTime } : undefined; }); const [paidTier, setPaidTier] = useState( undefined, ); const [isConfigInitialized, setConfigInitialized] = useState(false); const logger = useLogger(config.storage); const { inputHistory, addInput, initializeFromLogger } = useInputHistoryStore(); // Terminal and layout hooks const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); const { stdin, setRawMode } = useStdin(); const { stdout } = useStdout(); const app: AppProps = useApp(); // Additional hooks moved from App.tsx const { stats: sessionStats } = useSessionStats(); const branchName = useGitBranchName(config.getTargetDir()); // Layout measurements const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); const lastTitleRef = useRef(null); const staticExtraHeight = 3; useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { // Note: the program will not work if this fails so let errors be // handled by the global catch. if (!config.isInitialized()) { await config.initialize(); } setConfigInitialized(true); startupProfiler.flush(config); const sessionStartSource = resumedSessionData ? SessionStartSource.Resume : SessionStartSource.Startup; const result = await config .getHookSystem() ?.fireSessionStartEvent(sessionStartSource); if (result) { if (result.systemMessage) { historyManager.addItem( { type: MessageType.INFO, text: result.systemMessage, }, Date.now(), ); } const additionalContext = result.getAdditionalContext(); const geminiClient = config.getGeminiClient(); if (additionalContext && geminiClient) { await geminiClient.addHistory({ role: 'user', parts: [ { text: `${additionalContext}` }, ], }); } } // Fire-and-forget: generate summary for previous session in background generateSummary(config).catch((e) => { debugLogger.warn('Background summary generation failed:', e); }); })(); registerCleanup(async () => { // Turn off mouse scroll. disableMouseEvents(); // Kill all background shells await Promise.all( Array.from(backgroundShellsRef.current.keys()).map((pid) => ShellExecutionService.kill(pid), ), ); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); // Fire SessionEnd hook on cleanup (only if hooks are enabled) await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); }); // Disable the dependencies check here. historyManager gets flagged // but we don't want to react to changes to it because each new history // item, including the ones from the start session hook will cause a // re-render and an error when we try to reload config. // // eslint-disable-next-line react-hooks/exhaustive-deps }, [config, resumedSessionData]); useEffect( () => setUpdateHandler(historyManager.addItem, setUpdateInfo), [historyManager.addItem], ); // Subscribe to fallback mode and model changes from core useEffect(() => { const handleModelChanged = () => { setCurrentModel(config.getModel()); }; const handleQuotaChanged = (payload: { remaining: number | undefined; limit: number | undefined; resetTime?: string; }) => { setQuotaStats({ remaining: payload.remaining, limit: payload.limit, resetTime: payload.resetTime, }); }; coreEvents.on(CoreEvent.ModelChanged, handleModelChanged); coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged); return () => { coreEvents.off(CoreEvent.ModelChanged, handleModelChanged); coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged); }; }, [config]); useEffect(() => { const handleSettingsChanged = () => { setSettingsNonce((prev) => prev + 1); }; const handleAdminSettingsChanged = () => { setAdminSettingsChanged(true); }; const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => { setNewAgents(payload.agents); }; coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged); coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged); coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); return () => { coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged); coreEvents.off( CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged, ); coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); }; }, [settings]); const { errorCount, clearErrorCount } = useErrorCount(); const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = calculatePromptWidths(mainAreaWidth); return { inputWidth, suggestionsWidth }; }, [mainAreaWidth]); const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); const getPreferredEditor = useCallback( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion () => settings.merged.general.preferredEditor as EditorType, [settings.merged.general.preferredEditor], ); const buffer = useTextBuffer({ initialText: '', viewport: { height: 10, width: inputWidth }, stdin, setRawMode, escapePastedPaths: true, shellModeActive, getPreferredEditor, }); const bufferRef = useRef(buffer); useEffect(() => { bufferRef.current = buffer; }, [buffer]); const stableSetText = useCallback((text: string) => { bufferRef.current.setText(text); }, []); // Initialize input history from logger (past sessions) useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises initializeFromLogger(logger); }, [logger, initializeFromLogger]); // One-time prompt to suggest running /terminal-setup when it would help. useTerminalSetupPrompt({ addConfirmUpdateExtensionRequest, addItem: historyManager.addItem, }); const refreshStatic = useCallback(() => { if (!isAlternateBuffer) { stdout.write(ansiEscapes.clearTerminal); } setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, isAlternateBuffer, stdout]); const shouldUseAlternateScreen = shouldEnterAlternateScreen( isAlternateBuffer, config.getScreenReader(), ); const handleEditorClose = useCallback(() => { if (shouldUseAlternateScreen) { // The editor may have exited alternate buffer mode so we need to // enter it again to be safe. enterAlternateScreen(); enableMouseEvents(); disableLineWrapping(); app.rerender(); } terminalCapabilityManager.enableSupportedModes(); refreshStatic(); }, [refreshStatic, shouldUseAlternateScreen, app]); const [editorError, setEditorError] = useState(null); const { isEditorDialogOpen, openEditorDialog, handleEditorSelect, exitEditorDialog, } = useEditorSettings(settings, setEditorError, historyManager.addItem); useEffect(() => { coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog); return () => { coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose); coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog); }; }, [handleEditorClose, openEditorDialog]); useEffect(() => { if ( !(settings.merged.ui.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { // The header should show a banner but the Header is rendered in static // so we must trigger a static refresh for it to be visible. refreshStatic(); } }, [bannerVisible, bannerText, settings, config, refreshStatic]); const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); const { isThemeDialogOpen, openThemeDialog, closeThemeDialog, handleThemeSelect, handleThemeHighlight, } = useThemeCommand( settings, setThemeError, historyManager.addItem, initializationResult.themeError, refreshStatic, ); // Poll for terminal background color changes to auto-switch theme useTerminalTheme(handleThemeSelect, config, refreshStatic); const { authState, setAuthState, authError, onAuthError, apiKeyDefaultValue, reloadApiKey, accountSuspensionInfo, setAccountSuspensionInfo, } = useAuthCommand( settings, config, initializationResult.authError, initializationResult.accountSuspensionInfo, ); const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( {}, ); useEffect(() => { if (authState === AuthState.Authenticated && authContext.requiresRestart) { setAuthState(AuthState.AwaitingGoogleLoginRestart); setAuthContext({}); } }, [authState, authContext, setAuthState]); const { proQuotaRequest, handleProQuotaChoice, validationRequest, handleValidationChoice, // G1 AI Credits overageMenuRequest, handleOverageMenuChoice, emptyWalletRequest, handleEmptyWalletChoice, } = useQuotaAndFallback({ config, historyManager, userTier, paidTier, settings, setModelSwitchedFromQuotaError, onShowAuthSelection: () => setAuthState(AuthState.Updating), errorVerbosity: settings.merged.ui.errorVerbosity, }); // Derive auth state variables for backward compatibility with UIStateContext const isAuthDialogOpen = authState === AuthState.Updating; const isAuthenticating = authState === AuthState.Unauthenticated; // Session browser and resume functionality const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); const { loadHistoryForResume, isResuming } = useSessionResume({ config, historyManager, refreshStatic, isGeminiClientInitialized, setQuittingMessages, resumedSessionData, isAuthenticating, }); const { isSessionBrowserOpen, openSessionBrowser, closeSessionBrowser, handleResumeSession, handleDeleteSession: handleDeleteSessionSync, } = useSessionBrowser(config, loadHistoryForResume); // Wrap handleDeleteSession to return a Promise for UIActions interface const handleDeleteSession = useCallback( async (session: SessionInfo): Promise => { handleDeleteSessionSync(session); }, [handleDeleteSessionSync], ); // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { const previousAuthType = config.getContentGeneratorConfig()?.authType ?? 'unknown'; if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { setAuthContext({}); } await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); try { config.setRemoteAdminSettings(undefined); await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); logBillingEvent( config, new ApiKeyUpdatedEvent(previousAuthType, authType), ); } catch (e) { if (e instanceof ChangeAuthRequestedError) { return; } if (e instanceof ProjectIdRequiredError) { // OAuth succeeded but account setup requires project ID // Show the error message directly without "Failed to authenticate" prefix onAuthError(getErrorMessage(e)); return; } onAuthError( `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, ); return; } if ( authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { writeToStdout(` ---------------------------------------------------------------- Logging in with Google... Restarting Gemini CLI to continue. ---------------------------------------------------------------- `); await relaunchApp(); } } setAuthState(AuthState.Authenticated); }, [settings, config, setAuthState, onAuthError, setAuthContext], ); const handleApiKeySubmit = useCallback( async (apiKey: string) => { try { onAuthError(null); if (!apiKey.trim() && apiKey.length > 1) { onAuthError( 'API key cannot be empty string with length greater than 1.', ); return; } await saveApiKey(apiKey); await reloadApiKey(); await config.refreshAuth(AuthType.USE_GEMINI); setAuthState(AuthState.Authenticated); } catch (e) { onAuthError( `Failed to save API key: ${e instanceof Error ? e.message : String(e)}`, ); } }, [setAuthState, onAuthError, reloadApiKey, config], ); const handleApiKeyCancel = useCallback(() => { // Go back to auth method selection setAuthState(AuthState.Updating); }, [setAuthState]); // Sync user tier from config when authentication changes useEffect(() => { // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); setPaidTier(config.getUserPaidTier()); } }, [config, authState]); // Check for enforced auth type mismatch useEffect(() => { if ( settings.merged.security.auth.enforcedType && settings.merged.security.auth.selectedType && settings.merged.security.auth.enforcedType !== settings.merged.security.auth.selectedType ) { onAuthError( `Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`, ); } else if ( settings.merged.security.auth.selectedType && !settings.merged.security.auth.useExternal ) { // We skip validation for Gemini API key here because it might be stored // in the keychain, which we can't check synchronously. // The useAuth hook handles validation for this case. if (settings.merged.security.auth.selectedType === AuthType.USE_GEMINI) { return; } const error = validateAuthMethod( settings.merged.security.auth.selectedType, ); if (error) { onAuthError(error); } } }, [ settings.merged.security.auth.selectedType, settings.merged.security.auth.enforcedType, settings.merged.security.auth.useExternal, onAuthError, ]); const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); const { toggleVimEnabled } = useVimMode(); const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( () => {}, ); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); const { cleanUiDetailsVisible, setCleanUiDetailsVisible, toggleCleanUiDetailsVisible, revealCleanUiDetailsTemporarily, } = useVisibilityToggle(); const slashCommandActions = useMemo( () => ({ openAuthDialog: () => setAuthState(AuthState.Updating), openThemeDialog, openEditorDialog, openPrivacyNotice: () => setShowPrivacyNotice(true), openSettingsDialog, openSessionBrowser, openModelDialog, openAgentConfigDialog, openPermissionsDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); setTimeout(async () => { await runExitCleanup(); process.exit(0); }, 100); }, setDebugMessage, toggleCorgiMode: () => setCorgiMode((prev) => !prev), toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, toggleBackgroundShell: () => { toggleBackgroundShellRef.current(); if (!isBackgroundShellVisibleRef.current) { setEmbeddedShellFocused(true); if (backgroundShellsRef.current.size > 1) { setIsBackgroundShellListOpenRef.current(true); } else { setIsBackgroundShellListOpenRef.current(false); } } }, toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), setText: stableSetText, }), [ setAuthState, openThemeDialog, openEditorDialog, openSettingsDialog, openSessionBrowser, openModelDialog, openAgentConfigDialog, setQuittingMessages, setDebugMessage, setShowPrivacyNotice, setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, setShortcutsHelpVisible, stableSetText, ], ); const { handleSlashCommand, slashCommands, pendingHistoryItems: pendingSlashCommandHistoryItems, commandContext, confirmationRequest: commandConfirmationRequest, } = useSlashCommandProcessor( config, settings, historyManager.addItem, historyManager.clearItems, historyManager.loadHistory, refreshStatic, toggleVimEnabled, setIsProcessing, slashCommandActions, extensionsUpdateStateInternal, isConfigInitialized, setBannerVisible, setCustomDialog, ); const [authConsentRequest, setAuthConsentRequest] = useState(null); const [permissionConfirmationRequest, setPermissionConfirmationRequest] = useState(null); useEffect(() => { const handleConsentRequest = (payload: ConsentRequestPayload) => { setAuthConsentRequest({ prompt: payload.prompt, onConfirm: (confirmed: boolean) => { setAuthConsentRequest(null); payload.onConfirm(confirmed); }, }); }; coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest); return () => { coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest); }; }, []); const performMemoryRefresh = useCallback(async () => { historyManager.addItem( { type: MessageType.INFO, text: 'Refreshing hierarchical memory (GEMINI.md or other context files)...', }, Date.now(), ); try { let flattenedMemory: string; let fileCount: number; if (config.isJitContextEnabled()) { await config.getContextManager()?.refresh(); flattenedMemory = flattenMemory(config.getUserMemory()); fileCount = config.getGeminiMdFileCount(); } else { const result = await refreshServerHierarchicalMemory(config); flattenedMemory = flattenMemory(result.memoryContent); fileCount = result.fileCount; } historyManager.addItem( { type: MessageType.INFO, text: `Memory reloaded successfully. ${ flattenedMemory.length > 0 ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)` : 'No memory content found' }`, }, Date.now(), ); if (config.getDebugMode()) { debugLogger.log( `[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring( 0, 200, )}...`, ); } } catch (error) { const errorMessage = getErrorMessage(error); historyManager.addItem( { type: MessageType.ERROR, text: `Error refreshing memory: ${errorMessage}`, }, Date.now(), ); debugLogger.warn('Error refreshing memory:', error); } }, [config, historyManager]); const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>( () => {}, ); const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => { if (shouldRestorePrompt) { setPendingRestorePrompt(true); } else { setPendingRestorePrompt(false); cancelHandlerRef.current(false); } }, []); useEffect(() => { if (pendingRestorePrompt) { const lastHistoryUserMsg = historyManager.history.findLast( (h) => h.type === 'user', ); const lastUserMsg = inputHistory.at(-1); if ( !lastHistoryUserMsg || (typeof lastHistoryUserMsg.text === 'string' && lastHistoryUserMsg.text === lastUserMsg) ) { cancelHandlerRef.current(true); setPendingRestorePrompt(false); } } }, [pendingRestorePrompt, inputHistory, historyManager.history]); const pendingHintsRef = useRef([]); const [pendingHintCount, setPendingHintCount] = useState(0); const consumePendingHints = useCallback(() => { if (pendingHintsRef.current.length === 0) { return null; } const hint = pendingHintsRef.current.join('\n'); pendingHintsRef.current = []; setPendingHintCount(0); return hint; }, []); useEffect(() => { const hintListener = (text: string, source: InjectionSource) => { if (source !== 'user_steering') { return; } pendingHintsRef.current.push(text); setPendingHintCount((prev) => prev + 1); }; config.injectionService.onInjection(hintListener); return () => { config.injectionService.offInjection(hintListener); }; }, [config]); const { streamingState, submitQuery, initError, pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, pendingToolCalls, handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, lastOutputTime, backgroundShellCount, isBackgroundShellVisible, toggleBackgroundShell, backgroundCurrentShell, backgroundShells, dismissBackgroundShell, retryStatus, } = useGeminiStream( config.getGeminiClient(), historyManager.history, historyManager.addItem, config, settings, setDebugMessage, handleSlashCommand, shellModeActive, getPreferredEditor, onAuthError, performMemoryRefresh, modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, onCancelSubmit, setEmbeddedShellFocused, terminalWidth, terminalHeight, embeddedShellFocused, consumePendingHints, ); toggleBackgroundShellRef.current = toggleBackgroundShell; isBackgroundShellVisibleRef.current = isBackgroundShellVisible; backgroundShellsRef.current = backgroundShells; const { activeBackgroundShellPid, setIsBackgroundShellListOpen, isBackgroundShellListOpen, setActiveBackgroundShellPid, backgroundShellHeight, } = useBackgroundShellManager({ backgroundShells, backgroundShellCount, isBackgroundShellVisible, activePtyId, embeddedShellFocused, setEmbeddedShellFocused, terminalHeight, }); setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; const lastOutputTimeRef = useRef(0); useEffect(() => { lastOutputTimeRef.current = lastOutputTime; }, [lastOutputTime]); const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({ activePtyId, lastOutputTime, streamingState, pendingToolCalls, embeddedShellFocused, isInteractiveShellEnabled: config.isInteractiveShellEnabled(), }); const shouldShowActionRequiredTitle = inactivityStatus === 'action_required'; const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working'; const handleApprovalModeChangeWithUiReveal = useCallback( (mode: ApprovalMode) => { void handleApprovalModeChange(mode); if (!cleanUiDetailsVisible) { revealCleanUiDetailsTemporarily(APPROVAL_MODE_REVEAL_DURATION_MS); } }, [ handleApprovalModeChange, cleanUiDetailsVisible, revealCleanUiDetailsTemporarily, ], ); const { isMcpReady } = useMcpStatus(config); const { messageQueue, addMessage, clearQueue, getQueuedMessagesText, popAllMessages, } = useMessageQueue({ isConfigInitialized, streamingState, submitQuery, isMcpReady, }); cancelHandlerRef.current = useCallback( (shouldRestorePrompt: boolean = true) => { const pendingHistoryItems = [ ...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems, ]; if (isToolAwaitingConfirmation(pendingHistoryItems)) { return; // Don't clear - user may be composing a follow-up message } if (isToolExecuting(pendingHistoryItems)) { buffer.setText(''); // Clear for Ctrl+C cancellation return; } // If cancelling (shouldRestorePrompt=false), never modify the buffer // User is in control - preserve whatever text they typed, pasted, or restored if (!shouldRestorePrompt) { return; } // Restore the last message when shouldRestorePrompt=true const lastUserMessage = inputHistory.at(-1); let textToSet = lastUserMessage || ''; const queuedText = getQueuedMessagesText(); if (queuedText) { textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText; clearQueue(); } if (textToSet) { buffer.setText(textToSet); } }, [ buffer, inputHistory, getQueuedMessagesText, clearQueue, pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, ], ); const handleHintSubmit = useCallback( (hint: string) => { const trimmed = hint.trim(); if (!trimmed) { return; } config.injectionService.addInjection(trimmed, 'user_steering'); // Render hints with a distinct style. historyManager.addItem({ type: 'hint', text: trimmed, }); }, [config, historyManager], ); const handleFinalSubmit = useCallback( async (submittedValue: string) => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. triggerExpandHint(null); if (!constrainHeight) { setConstrainHeight(true); if (!isAlternateBuffer) { refreshStatic(); } } const isSlash = isSlashCommand(submittedValue.trim()); const isIdle = streamingState === StreamingState.Idle; const isAgentRunning = streamingState === StreamingState.Responding || isToolExecuting([ ...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems, ]); if (isSlash && isAgentRunning) { const { commandToExecute } = parseSlashCommand( submittedValue, slashCommands ?? [], ); if (commandToExecute?.isSafeConcurrent) { void handleSlashCommand(submittedValue); addInput(submittedValue); return; } } if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) { handleHintSubmit(submittedValue); addInput(submittedValue); return; } if (isSlash || (isIdle && isMcpReady)) { if (!isSlash) { const permissions = await checkPermissions(submittedValue, config); if (permissions.length > 0) { setPermissionConfirmationRequest({ files: permissions, onComplete: (result) => { setPermissionConfirmationRequest(null); if (result.allowed) { permissions.forEach((p) => config.getWorkspaceContext().addReadOnlyPath(p), ); } void submitQuery(submittedValue); }, }); addInput(submittedValue); return; } } void submitQuery(submittedValue); } else { // Check messageQueue.length === 0 to only notify on the first queued item if (isIdle && !isMcpReady && messageQueue.length === 0) { coreEvents.emitFeedback( 'info', 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.', ); } addMessage(submittedValue); } addInput(submittedValue); // Track input for up-arrow history }, [ addMessage, addInput, submitQuery, handleSlashCommand, slashCommands, isMcpReady, streamingState, messageQueue.length, pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, config, constrainHeight, setConstrainHeight, isAlternateBuffer, refreshStatic, reset, handleHintSubmit, triggerExpandHint, ], ); const handleClearScreen = useCallback(() => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. triggerExpandHint(null); historyManager.clearItems(); clearErrorCount(); refreshStatic(); }, [ historyManager, clearErrorCount, refreshStatic, reset, triggerExpandHint, ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); /** * Determines if the input prompt should be active and accept user input. * Input is disabled during: * - Initialization errors * - Slash command processing * - Tool confirmations (WaitingForConfirmation state) * - Any future streaming states not explicitly allowed */ const isInputActive = isConfigInitialized && !initError && !isProcessing && !isResuming && !!slashCommands && (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding) && !proQuotaRequest; const [controlsHeight, setControlsHeight] = useState(0); useLayoutEffect(() => { if (mainControlsRef.current) { const fullFooterMeasurement = measureElement(mainControlsRef.current); const roundedHeight = Math.round(fullFooterMeasurement.height); if (roundedHeight > 0 && roundedHeight !== controlsHeight) { setControlsHeight(roundedHeight); } } }, [buffer, terminalWidth, terminalHeight, controlsHeight]); // Compute available terminal height based on controls measurement const availableTerminalHeight = Math.max( 0, terminalHeight - controlsHeight - backgroundShellHeight - 1, ); config.setShellExecutionConfig({ terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), terminalHeight: Math.max( Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1, ), pager: settings.merged.tools.shell.pager, showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, sandboxManager: config.sandboxManager, }); const { isFocused, hasReceivedFocusEvent } = useFocus(); // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context.fileName; return fromSettings ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] : getAllGeminiMdFilenames(); }, [settings.merged.context.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); const geminiClient = config.getGeminiClient(); useEffect(() => { if ( initialPrompt && isConfigInitialized && !initialPromptSubmitted.current && !isAuthenticating && !isAuthDialogOpen && !isThemeDialogOpen && !isEditorDialogOpen && !showPrivacyNotice && geminiClient?.isInitialized?.() ) { void handleFinalSubmit(initialPrompt); initialPromptSubmitted.current = true; } }, [ initialPrompt, isConfigInitialized, handleFinalSubmit, isAuthenticating, isAuthDialogOpen, isThemeDialogOpen, isEditorDialogOpen, showPrivacyNotice, geminiClient, ]); const [idePromptAnswered, setIdePromptAnswered] = useState(false); const [currentIDE, setCurrentIDE] = useState(null); useEffect(() => { const getIde = async () => { const ideClient = await IdeClient.getInstance(); const currentIde = ideClient.getCurrentIde(); setCurrentIDE(currentIde || null); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises getIde(); }, []); const shouldShowIdePrompt = Boolean( currentIDE && !config.getIdeMode() && !settings.merged.ide.hasSeenNudge && !idePromptAnswered, ); const [showErrorDetails, setShowErrorDetails] = useState(false); const [showFullTodos, setShowFullTodos] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); const handleExitRepeat = useCallback( (count: number) => { if (count > 2) { recordExitFail(config); } if (count > 1) { void handleSlashCommand('/quit', undefined, undefined, false); } }, [config, handleSlashCommand], ); const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } = useRepeatedKeyPress({ windowMs: WARNING_PROMPT_DURATION_MS, onRepeat: handleExitRepeat, }); const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } = useRepeatedKeyPress({ windowMs: WARNING_PROMPT_DURATION_MS, onRepeat: handleExitRepeat, }); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [transientMessage, showTransientMessage] = useTimedMessage<{ text: string; type: TransientMessageType; }>(WARNING_PROMPT_DURATION_MS); const { isFolderTrustDialogOpen, discoveryResults: folderDiscoveryResults, handleFolderTrustSelect, isRestarting, } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); const policyUpdateConfirmationRequest = config.getPolicyUpdateConfirmationRequest(); const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( !!policyUpdateConfirmationRequest, ); const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, } = useIdeTrustListener(); const isInitialMount = useRef(true); useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); const tabFocusTimeoutRef = useRef(null); useEffect(() => { const handleTransientMessage = (payload: { message: string; type: TransientMessageType; }) => { showTransientMessage({ text: payload.message, type: payload.type }); }; const handleSelectionWarning = () => { showTransientMessage({ text: 'Press Ctrl-S to enter selection mode to copy text.', type: TransientMessageType.Warning, }); }; const handlePasteTimeout = () => { showTransientMessage({ text: 'Paste Timed out. Possibly due to slow connection.', type: TransientMessageType.Warning, }); }; appEvents.on(AppEvent.TransientMessage, handleTransientMessage); appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout); return () => { appEvents.off(AppEvent.TransientMessage, handleTransientMessage); appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } }; }, [showTransientMessage]); const handleWarning = useCallback( (message: string) => { showTransientMessage({ text: message, type: TransientMessageType.Warning, }); }, [showTransientMessage], ); const { handleSuspend } = useSuspend({ handleWarning, setRawMode, refreshStatic, setForceRerenderKey, shouldUseAlternateScreen, }); useEffect(() => { if (ideNeedsRestart) { // IDE trust changed, force a restart. setShowIdeRestartPrompt(true); } }, [ideNeedsRestart]); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; return; } const handler = setTimeout(() => { refreshStatic(); }, 300); return () => { clearTimeout(handler); }; }, [terminalWidth, refreshStatic]); useEffect(() => { const unsubscribe = ideContextStore.subscribe(setIdeContextState); setIdeContextState(ideContextStore.get()); return unsubscribe; }, []); useEffect(() => { const openDebugConsole = () => { setShowErrorDetails(true); setConstrainHeight(false); }; appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); return () => { appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); }; }, [config]); const handleEscapePromptChange = useCallback((showPrompt: boolean) => { setShowEscapePrompt(showPrompt); }, []); const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/ide install'); settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } else if (result.userSelection === 'dismiss') { settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } setIdePromptAnswered(true); }, [handleSlashCommand, settings], ); const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({ streamingState, shouldShowFocusHint, retryStatus, loadingPhrasesMode: settings.merged.ui.loadingPhrases, customWittyPhrases: settings.merged.ui.customWittyPhrases, errorVerbosity: settings.merged.ui.errorVerbosity, }); const handleGlobalKeypress = useCallback( (key: Key): boolean => { if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); return true; } if (keyMatchers[Command.QUIT](key)) { // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. cancelOngoingRequest?.(); handleCtrlCPress(); return true; } else if (keyMatchers[Command.EXIT](key)) { handleCtrlDPress(); return true; } else if (keyMatchers[Command.SUSPEND_APP](key)) { handleSuspend(); } else if ( keyMatchers[Command.TOGGLE_COPY_MODE](key) && !isAlternateBuffer ) { showTransientMessage({ text: 'Use Ctrl+O to expand and collapse blocks of content.', type: TransientMessageType.Warning, }); return true; } let enteringConstrainHeightMode = false; if (!constrainHeight) { enteringConstrainHeightMode = true; setConstrainHeight(true); if (keyMatchers[Command.SHOW_MORE_LINES](key)) { // If the user manually collapses the view, show the hint and reset the x-second timer. triggerExpandHint(true); } if (!isAlternateBuffer) { refreshStatic(); } } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { if (settings.merged.general.devtools) { void (async () => { const { toggleDevToolsPanel } = await import( '../utils/devtoolsService.js' ); await toggleDevToolsPanel( config, showErrorDetails, () => setShowErrorDetails((prev) => !prev), () => setShowErrorDetails(true), ); })(); } else { setShowErrorDetails((prev) => !prev); } return true; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); return true; } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; // Force re-render of static content refreshStatic(); return newValue; }); return true; } else if ( keyMatchers[Command.SHOW_IDE_CONTEXT_DETAIL](key) && config.getIdeMode() && ideContextState ) { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/ide status'); return true; } else if ( keyMatchers[Command.SHOW_MORE_LINES](key) && !enteringConstrainHeightMode ) { setConstrainHeight(false); // If the user manually expands the view, show the hint and reset the x-second timer. triggerExpandHint(true); if (!isAlternateBuffer) { refreshStatic(); } return true; } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { if (embeddedShellFocused) { const capturedTime = lastOutputTimeRef.current; if (tabFocusTimeoutRef.current) clearTimeout(tabFocusTimeoutRef.current); tabFocusTimeoutRef.current = setTimeout(() => { if (lastOutputTimeRef.current === capturedTime) { setEmbeddedShellFocused(false); } else { showTransientMessage({ text: 'Use Shift+Tab to unfocus', type: TransientMessageType.Warning, }); } }, 150); return false; } const isIdle = Date.now() - lastOutputTimeRef.current >= 100; if (isIdle && !activePtyId && !isBackgroundShellVisible) { if (tabFocusTimeoutRef.current) clearTimeout(tabFocusTimeoutRef.current); toggleBackgroundShell(); setEmbeddedShellFocused(true); if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true); return true; } setEmbeddedShellFocused(true); return true; } else if ( keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) || keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ) { if (embeddedShellFocused) { setEmbeddedShellFocused(false); return true; } return false; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (activePtyId) { backgroundCurrentShell(); // After backgrounding, we explicitly do NOT show or focus the background UI. } else { toggleBackgroundShell(); // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. if (!isBackgroundShellVisible && backgroundShells.size > 0) { setEmbeddedShellFocused(true); if (backgroundShells.size > 1) { setIsBackgroundShellListOpen(true); } } else { setEmbeddedShellFocused(false); } } return true; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { if (backgroundShells.size > 0 && isBackgroundShellVisible) { if (!embeddedShellFocused) { setEmbeddedShellFocused(true); } setIsBackgroundShellListOpen(true); } return true; } return false; }, [ constrainHeight, setConstrainHeight, setShowErrorDetails, config, ideContextState, handleCtrlCPress, handleCtrlDPress, handleSlashCommand, cancelOngoingRequest, activePtyId, handleSuspend, embeddedShellFocused, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, isAlternateBuffer, shortcutsHelpVisible, backgroundCurrentShell, toggleBackgroundShell, backgroundShells, isBackgroundShellVisible, setIsBackgroundShellListOpen, lastOutputTimeRef, showTransientMessage, settings.merged.general.devtools, showErrorDetails, triggerExpandHint, keyMatchers, isHelpDismissKey, ], ); useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useKeypress( (key: Key) => { if ( keyMatchers[Command.SCROLL_UP](key) || keyMatchers[Command.SCROLL_DOWN](key) || keyMatchers[Command.PAGE_UP](key) || keyMatchers[Command.PAGE_DOWN](key) || keyMatchers[Command.SCROLL_HOME](key) || keyMatchers[Command.SCROLL_END](key) ) { return false; } setCopyModeEnabled(false); enableMouseEvents(); return true; }, { isActive: copyModeEnabled, // We need to receive keypresses first so they do not bubble to other // handlers. priority: KeypressPriority.Critical, }, ); useEffect(() => { // Respect hideWindowTitle settings if (settings.merged.ui.hideWindowTitle) return; const paddedTitle = computeTerminalTitle({ streamingState, thoughtSubject: thought?.subject, isConfirming: !!commandConfirmationRequest || shouldShowActionRequiredTitle, isSilentWorking: shouldShowSilentWorkingTitle, folderName: basename(config.getTargetDir()), showThoughts: !!settings.merged.ui.showStatusInTitle, useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); // Only update the title if it's different from the last value we set if (lastTitleRef.current !== paddedTitle) { lastTitleRef.current = paddedTitle; stdout.write(`\x1b]0;${paddedTitle}\x07`); } // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere }, [ streamingState, thought, commandConfirmationRequest, shouldShowActionRequiredTitle, shouldShowSilentWorkingTitle, settings.merged.ui.showStatusInTitle, settings.merged.ui.dynamicWindowTitle, settings.merged.ui.hideWindowTitle, config, stdout, ]); useEffect(() => { const handleUserFeedback = (payload: UserFeedbackPayload) => { let type: MessageType; switch (payload.severity) { case 'error': type = MessageType.ERROR; break; case 'warning': type = MessageType.WARNING; break; case 'info': type = MessageType.INFO; break; default: throw new Error( `Unexpected severity for user feedback: ${payload.severity}`, ); } historyManager.addItem( { type, text: payload.message, }, Date.now(), ); // If there is an attached error object, log it to the debug drawer. if (payload.error) { debugLogger.warn( `[Feedback Details for "${payload.message}"]`, payload.error, ); } }; coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); // Flush any messages that happened during startup before this component // mounted. coreEvents.drainBacklogs(); return () => { coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); }; }, [historyManager]); const nightly = props.version.includes('nightly'); const dialogsVisible = shouldShowIdePrompt || shouldShowIdePrompt || isFolderTrustDialogOpen || isPolicyUpdateDialogOpen || adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || !!permissionConfirmationRequest || !!customDialog || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || isAgentConfigDialogOpen || isPermissionsDialogOpen || isAuthenticating || isAuthDialogOpen || isEditorDialogOpen || showPrivacyNotice || showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || !!overageMenuRequest || !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!newAgents; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); const hasPendingToolConfirmation = useMemo( () => isToolAwaitingConfirmation(pendingHistoryItems), [pendingHistoryItems], ); const hasConfirmUpdateExtensionRequests = confirmUpdateExtensionRequests.length > 0; const hasLoopDetectionConfirmationRequest = !!loopDetectionConfirmationRequest; const hasPendingActionRequired = hasPendingToolConfirmation || !!commandConfirmationRequest || !!authConsentRequest || hasConfirmUpdateExtensionRequests || hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || !!overageMenuRequest || !!emptyWalletRequest || !!customDialog; const allowPlanMode = config.isPlanEnabled() && streamingState === StreamingState.Idle && !hasPendingActionRequired; const showApprovalModeIndicator = useApprovalModeIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChangeWithUiReveal, isActive: !embeddedShellFocused, allowPlanMode, }); useRunEventNotifications({ notificationsEnabled, isFocused, hasReceivedFocusEvent, streamingState, hasPendingActionRequired, pendingHistoryItems, commandConfirmationRequest, authConsentRequest, permissionConfirmationRequest, hasConfirmUpdateExtensionRequests, hasLoopDetectionConfirmationRequest, }); const isPassiveShortcutsHelpState = isInputActive && streamingState === StreamingState.Idle && !hasPendingActionRequired; useEffect(() => { if (shortcutsHelpVisible && !isPassiveShortcutsHelpState) { setShortcutsHelpVisible(false); } }, [ shortcutsHelpVisible, isPassiveShortcutsHelpState, setShortcutsHelpVisible, ]); useEffect(() => { if ( !isConfigInitialized || !config.isModelSteeringEnabled() || streamingState !== StreamingState.Idle || !isMcpReady || isToolAwaitingConfirmation(pendingHistoryItems) ) { return; } const pendingHint = consumePendingHints(); if (!pendingHint) { return; } void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]); }, [ config, historyManager, isConfigInitialized, isMcpReady, streamingState, submitQuery, consumePendingHints, pendingHistoryItems, pendingHintCount, ]); const allToolCalls = useMemo( () => pendingHistoryItems .filter( (item): item is HistoryItemToolGroup => item.type === 'tool_group', ) .flatMap((item) => item.tools), [pendingHistoryItems], ); const [geminiMdFileCount, setGeminiMdFileCount] = useState( config.getGeminiMdFileCount(), ); useEffect(() => { const handleMemoryChanged = (result: MemoryChangedPayload) => { setGeminiMdFileCount(result.fileCount); }; coreEvents.on(CoreEvent.MemoryChanged, handleMemoryChanged); return () => { coreEvents.off(CoreEvent.MemoryChanged, handleMemoryChanged); }; }, []); useEffect(() => { let isMounted = true; const fetchBannerTexts = async () => { const [defaultBanner, warningBanner] = await Promise.all([ config.getBannerTextNoCapacityIssues(), config.getBannerTextCapacityIssues(), ]); if (isMounted) { setDefaultBannerText(defaultBanner); setWarningBannerText(warningBanner); setBannerVisible(true); } }; // eslint-disable-next-line @typescript-eslint/no-floating-promises fetchBannerTexts(); return () => { isMounted = false; }; }, [config, refreshStatic]); const uiState: UIState = useMemo( () => ({ history: historyManager.history, historyManager, isThemeDialogOpen, themeError, isAuthenticating, isConfigInitialized, authError, accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, editorError, isEditorDialogOpen, showPrivacyNotice, corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, isAgentConfigDialogOpen, selectedAgentName, selectedAgentDisplayName, selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, commandConfirmationRequest, authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, permissionConfirmationRequest, geminiMdFileCount, streamingState, initError, pendingGeminiHistoryItems, thought, shellModeActive, userMessages: inputHistory, buffer, inputWidth, suggestionsWidth, isInputActive, isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, showFullTodos, ideContextState, renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, elapsedTime, currentLoadingPhrase, historyRemountKey, activeHooks, messageQueue, queueErrorMessage, showApprovalModeIndicator, allowPlanMode, currentModel, quota: { userTier, stats: quotaStats, proQuotaRequest, validationRequest, // G1 AI Credits dialog state overageMenuRequest, emptyWalletRequest, }, contextFileNames, errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, dialogsVisible, pendingHistoryItems, nightly, branchName, sessionStats, terminalWidth, terminalHeight, mainControlsRef, rootUiRef, currentIDE, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, isRestarting, extensionsUpdateState, activePtyId, backgroundShellCount, isBackgroundShellVisible, embeddedShellFocused, showDebugProfiler, customDialog, copyModeEnabled, transientMessage, bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, backgroundShells, activeBackgroundShellPid, backgroundShellHeight, isBackgroundShellListOpen, adminSettingsChanged, newAgents, showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting([ ...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems, ]), hintBuffer: '', }), [ isThemeDialogOpen, themeError, isAuthenticating, isConfigInitialized, authError, accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, showPrivacyNotice, corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, isAgentConfigDialogOpen, selectedAgentName, selectedAgentDisplayName, selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, commandConfirmationRequest, authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, permissionConfirmationRequest, geminiMdFileCount, streamingState, initError, pendingGeminiHistoryItems, thought, shellModeActive, inputHistory, buffer, inputWidth, suggestionsWidth, isInputActive, isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, showFullTodos, ideContextState, renderMarkdown, ctrlCPressCount, ctrlDPressCount, showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, elapsedTime, currentLoadingPhrase, historyRemountKey, activeHooks, messageQueue, queueErrorMessage, showApprovalModeIndicator, allowPlanMode, userTier, quotaStats, proQuotaRequest, validationRequest, overageMenuRequest, emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, dialogsVisible, pendingHistoryItems, nightly, branchName, sessionStats, terminalWidth, terminalHeight, mainControlsRef, rootUiRef, currentIDE, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, isRestarting, currentModel, extensionsUpdateState, activePtyId, backgroundShellCount, isBackgroundShellVisible, historyManager, embeddedShellFocused, showDebugProfiler, customDialog, apiKeyDefaultValue, authState, copyModeEnabled, transientMessage, bannerData, bannerVisible, config, settingsNonce, backgroundShellHeight, isBackgroundShellListOpen, activeBackgroundShellPid, backgroundShells, adminSettingsChanged, newAgents, showIsExpandableHint, ], ); const exitPrivacyNotice = useCallback( () => setShowPrivacyNotice(false), [setShowPrivacyNotice], ); const uiActions: UIActions = useMemo( () => ({ handleThemeSelect, closeThemeDialog, handleThemeHighlight, handleAuthSelect, setAuthState, onAuthError, handleEditorSelect, exitEditorDialog, exitPrivacyNotice, closeSettingsDialog, closeModelDialog, openAgentConfigDialog, closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, setIsPolicyUpdateDialogOpen, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, handleFinalSubmit, handleClearScreen, handleProQuotaChoice, handleValidationChoice, // G1 AI Credits handlers handleOverageMenuChoice, handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, handleDeleteSession, setQueueErrorMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, setCleanUiDetailsVisible, toggleCleanUiDetailsVisible, revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, onHintInput: () => {}, onHintBackspace: () => {}, onHintClear: () => {}, onHintSubmit: () => {}, handleRestart: async () => { if (process.send) { const remoteSettings = config.getRemoteAdminSettings(); if (remoteSettings) { process.send({ type: 'admin-settings-update', settings: remoteSettings, }); } } await relaunchApp(); }, handleNewAgentsSelect: async (choice: NewAgentsChoice) => { if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { const registry = config.getAgentRegistry(); try { await Promise.all( newAgents.map((agent) => registry.acknowledgeAgent(agent)), ); } catch (error) { debugLogger.error('Failed to acknowledge agents:', error); historyManager.addItem( { type: MessageType.ERROR, text: `Failed to acknowledge agents: ${getErrorMessage(error)}`, }, Date.now(), ); } } setNewAgents(null); }, getPreferredEditor, clearAccountSuspension: () => { setAccountSuspensionInfo(null); setAuthState(AuthState.Updating); }, }), [ handleThemeSelect, closeThemeDialog, handleThemeHighlight, handleAuthSelect, setAuthState, onAuthError, handleEditorSelect, exitEditorDialog, exitPrivacyNotice, closeSettingsDialog, closeModelDialog, openAgentConfigDialog, closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, setIsPolicyUpdateDialogOpen, setConstrainHeight, handleEscapePromptChange, refreshStatic, handleFinalSubmit, handleClearScreen, handleProQuotaChoice, handleValidationChoice, handleOverageMenuChoice, handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, handleDeleteSession, setQueueErrorMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, setCleanUiDetailsVisible, toggleCleanUiDetailsVisible, revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, setAccountSuspensionInfo, newAgents, config, historyManager, getPreferredEditor, ], ); if (authState === AuthState.AwaitingGoogleLoginRestart) { return ( { setAuthContext({}); setAuthState(AuthState.Updating); }} config={config} /> ); } return ( ); }; ================================================ FILE: packages/cli/src/ui/IdeIntegrationNudge.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { renderWithProviders } from '../test-utils/render.js'; import { act } from 'react'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock debugLogger vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, }; }); describe('IdeIntegrationNudge', () => { const defaultProps = { ide: { name: 'vscode', displayName: 'VS Code', }, onComplete: vi.fn(), }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); beforeEach(() => { vi.mocked(debugLogger.warn).mockImplementation((...args) => { if ( typeof args[0] === 'string' && /was not wrapped in act/.test(args[0]) ) { return; } }); vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', ''); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); }); it('renders correctly with default options', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); expect(frame).toContain('Yes'); expect(frame).toContain('No (esc)'); expect(frame).toContain("No, don't ask again"); unmount(); }); it('handles "Yes" selection', async () => { const onComplete = vi.fn(); const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); }); await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'yes', isExtensionPreInstalled: false, }); unmount(); }); it('handles "No" selection', async () => { const onComplete = vi.fn(); const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow }); await waitUntilReady(); await act(async () => { stdin.write('\r'); // Enter }); await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'no', isExtensionPreInstalled: false, }); unmount(); }); it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow }); await waitUntilReady(); await act(async () => { stdin.write('\u001B[B'); // Down arrow }); await waitUntilReady(); await act(async () => { stdin.write('\r'); // Enter }); await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'dismiss', isExtensionPreInstalled: false, }); unmount(); }); it('handles Escape key press', async () => { const onComplete = vi.fn(); const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); // Press Escape await act(async () => { stdin.write('\u001B'); }); // Escape key has a timeout in KeypressContext, so we need to wrap waitUntilReady in act await act(async () => { await waitUntilReady(); }); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'no', isExtensionPreInstalled: false, }); unmount(); }); it('displays correct text and handles selection when extension is pre-installed', async () => { vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '1234'); vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); const { lastFrame, stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain( 'If you select Yes, the CLI will have access to your open files', ); expect(frame).not.toContain("we'll install an extension"); // Select "Yes" await act(async () => { stdin.write('\r'); }); await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'yes', isExtensionPreInstalled: true, }); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/IdeIntegrationNudge.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { IdeInfo } from '@google/gemini-cli-core'; import { Box, Text } from 'ink'; import { RadioButtonSelect, type RadioSelectItem, } from './components/shared/RadioButtonSelect.js'; import { useKeypress } from './hooks/useKeypress.js'; import { theme } from './semantic-colors.js'; export type IdeIntegrationNudgeResult = { userSelection: 'yes' | 'no' | 'dismiss'; isExtensionPreInstalled: boolean; }; interface IdeIntegrationNudgeProps { ide: IdeInfo; onComplete: (result: IdeIntegrationNudgeResult) => void; } export function IdeIntegrationNudge({ ide, onComplete, }: IdeIntegrationNudgeProps) { useKeypress( (key) => { if (key.name === 'escape') { onComplete({ userSelection: 'no', isExtensionPreInstalled: false, }); return true; } return false; }, { isActive: true }, ); const { displayName: ideName } = ide; // Assume extension is already installed if the env variables are set. const isExtensionPreInstalled = !!process.env['GEMINI_CLI_IDE_SERVER_PORT'] && !!process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; const OPTIONS: Array> = [ { label: 'Yes', value: { userSelection: 'yes', isExtensionPreInstalled, }, key: 'Yes', }, { label: 'No (esc)', value: { userSelection: 'no', isExtensionPreInstalled, }, key: 'No (esc)', }, { label: "No, don't ask again", value: { userSelection: 'dismiss', isExtensionPreInstalled, }, key: "No, don't ask again", }, ]; const installText = isExtensionPreInstalled ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ ideName ?? 'your editor' }.` : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ ideName ?? 'your editor' }.`; return ( {'> '} {`Do you want to connect ${ideName ?? 'your editor'} to Gemini CLI?`} {installText} ); } ================================================ FILE: packages/cli/src/ui/__snapshots__/App.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`App > Snapshots > renders default layout correctly 1`] = ` " ▝▜▄ Gemini CLI v1.2.3 ▝▜▄ ▗▟▀ ▝▀ Tips for getting started: 1. Create GEMINI.md files to customize your interactions 2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results Notifications Composer " `; exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer ▝▜▄ Gemini CLI v1.2.3 ▝▜▄ ▗▟▀ ▝▀ Tips for getting started: 1. Create GEMINI.md files to customize your interactions 2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results Composer " `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` " ▝▜▄ Gemini CLI v1.2.3 ▝▜▄ ▗▟▀ ▝▀ Notifications DialogManager " `; exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " ▝▜▄ Gemini CLI v1.2.3 ▝▜▄ ▗▟▀ ▝▀ Tips for getting started: 1. Create GEMINI.md files to customize your interactions 2. /help for more information 3. Ask coding questions, edit code or run commands 4. Be specific for the best results HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ │ │ │ ? ls list directory │ │ │ │ ls │ │ Allow execution of: 'ls'? │ │ │ │ ● 1. Allow once │ │ 2. Allow for this session │ │ 3. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ Notifications Composer " `; ================================================ FILE: packages/cli/src/ui/auth/ApiAuthDialog.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { ApiAuthDialog } from './ApiAuthDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useTextBuffer, type TextBuffer, } from '../components/shared/text-buffer.js'; import { clearApiKey } from '@google/gemini-cli-core'; // Mocks vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, clearApiKey: vi.fn().mockResolvedValue(undefined), }; }); vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { const actual = await importOriginal< typeof import('../components/shared/text-buffer.js') >(); return { ...actual, useTextBuffer: vi.fn(), }; }); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ terminalWidth: 80, })), })); const mockedUseKeypress = useKeypress as Mock; const mockedUseTextBuffer = useTextBuffer as Mock; describe('ApiAuthDialog', () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); let mockBuffer: TextBuffer; beforeEach(() => { vi.clearAllMocks(); vi.stubEnv('GEMINI_API_KEY', ''); mockBuffer = { text: '', lines: [''], cursor: [0, 0], visualCursor: [0, 0], viewportVisualLines: [''], handleInput: vi.fn(), setText: vi.fn((newText) => { mockBuffer.text = newText; mockBuffer.viewportVisualLines = [newText]; }), } as unknown as TextBuffer; mockedUseTextBuffer.mockReturnValue(mockBuffer); }); it('renders correctly', async () => { const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with a defaultValue', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', viewport: expect.objectContaining({ height: 4, }), }), ); unmount(); }); it.each([ { keyName: 'enter', sequence: '\r', expectedCall: onSubmit, args: ['submitted-key'], }, { keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] }, ])( 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[1] is the TextInput's useKeypress (typing handler) const keypressHandler = mockedUseKeypress.mock.calls[1][0]; keypressHandler({ name: keyName, shift: false, alt: false, ctrl: false, cmd: false, sequence, }); expect(expectedCall).toHaveBeenCalledWith(...args); unmount(); }, ); it('displays an error message', async () => { const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'c', shift: false, ctrl: true, cmd: false, }); await waitFor(() => { expect(clearApiKey).toHaveBeenCalled(); expect(mockBuffer.setText).toHaveBeenCalledWith(''); }); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/auth/ApiAuthDialog.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useRef, useEffect } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { TextInput } from '../components/shared/TextInput.js'; import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { Command } from '../key/keyMatchers.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface ApiAuthDialogProps { onSubmit: (apiKey: string) => void; onCancel: () => void; error?: string | null; defaultValue?: string; } export function ApiAuthDialog({ onSubmit, onCancel, error, defaultValue = '', }: ApiAuthDialogProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); const { terminalWidth } = useUIState(); const viewportWidth = terminalWidth - 8; const pendingPromise = useRef<{ cancel: () => void } | null>(null); useEffect( () => () => { pendingPromise.current?.cancel(); }, [], ); const initialApiKey = defaultValue; const buffer = useTextBuffer({ initialText: initialApiKey || '', initialCursorOffset: initialApiKey?.length || 0, viewport: { width: viewportWidth, height: 4, }, inputFilter: (text) => text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''), singleLine: true, }); const handleSubmit = (value: string) => { onSubmit(value); }; const handleClear = () => { pendingPromise.current?.cancel(); let isCancelled = false; const wrappedPromise = new Promise((resolve, reject) => { clearApiKey().then( () => !isCancelled && resolve(), (error) => !isCancelled && reject(error), ); }); pendingPromise.current = { cancel: () => { isCancelled = true; }, }; return wrappedPromise .then(() => { buffer.setText(''); }) .catch((err) => { debugLogger.debug('Failed to clear API key:', err); }); }; useKeypress( (key) => { if (keyMatchers[Command.CLEAR_INPUT](key)) { void handleClear(); return true; } return false; }, { isActive: true }, ); return ( Enter Gemini API Key Please enter your Gemini API key. It will be securely stored in your system keychain. You can get an API key from{' '} https://aistudio.google.com/app/apikey {error && ( {error} )} (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) ); } ================================================ FILE: packages/cli/src/ui/auth/AuthDialog.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import { AuthDialog } from './AuthDialog.js'; import { AuthType, type Config, debugLogger } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import { AuthState } from '../types.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { validateAuthMethodWithSettings } from './useAuth.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { Text } from 'ink'; import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; // Mocks vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, clearCachedCredentialFile: vi.fn(), }; }); vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: vi.fn(), })); vi.mock('./useAuth.js', () => ({ validateAuthMethodWithSettings: vi.fn(), })); vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); vi.mock('../components/shared/RadioButtonSelect.js', () => ({ RadioButtonSelect: vi.fn(({ items, initialIndex }) => ( <> {items.map((item: { value: string; label: string }, index: number) => ( {index === initialIndex ? '(selected)' : '(not selected)'}{' '} {item.label} ))} )), })); const mockedUseKeypress = useKeypress as Mock; const mockedRadioButtonSelect = RadioButtonSelect as Mock; const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock; const mockedRunExitCleanup = runExitCleanup as Mock; describe('AuthDialog', () => { let props: { config: Config; settings: LoadedSettings; setAuthState: (state: AuthState) => void; authError: string | null; onAuthError: (error: string | null) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; }; beforeEach(() => { vi.resetAllMocks(); vi.stubEnv('CLOUD_SHELL', undefined as unknown as string); vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string); vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string); vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string); props = { config: { isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), } as unknown as Config, settings: { merged: { security: { auth: {}, }, }, setValue: vi.fn(), } as unknown as LoadedSettings, setAuthState: vi.fn(), authError: null, onAuthError: vi.fn(), setAuthContext: vi.fn(), }; }); afterEach(() => { vi.unstubAllEnvs(); }); describe('Environment Variable Effects on Auth Options', () => { const cloudShellLabel = 'Use Cloud Shell user credentials'; const metadataServerLabel = 'Use metadata server application default credentials'; const computeAdcItem = (label: string) => ({ label, value: AuthType.COMPUTE_ADC, key: AuthType.COMPUTE_ADC, }); it.each([ { env: { CLOUD_SHELL: 'true' }, shouldContain: [computeAdcItem(cloudShellLabel)], shouldNotContain: [computeAdcItem(metadataServerLabel)], desc: 'in Cloud Shell', }, { env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' }, shouldContain: [computeAdcItem(metadataServerLabel)], shouldNotContain: [computeAdcItem(cloudShellLabel)], desc: 'with GEMINI_CLI_USE_COMPUTE_ADC', }, { env: {}, shouldContain: [], shouldNotContain: [ computeAdcItem(cloudShellLabel), computeAdcItem(metadataServerLabel), ], desc: 'by default', }, ])( 'correctly shows/hides COMPUTE_ADC options $desc', async ({ env, shouldContain, shouldNotContain }) => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); } for (const item of shouldNotContain) { expect(items).not.toContainEqual(item); } unmount(); }, ); }); it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); unmount(); }); it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); unmount(); }); describe('Initial Auth Type Selection', () => { it.each([ { setup: () => { props.settings.merged.security.auth.selectedType = AuthType.USE_VERTEX_AI; }, expected: AuthType.USE_VERTEX_AI, desc: 'from settings', }, { setup: () => { vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI); }, expected: AuthType.USE_GEMINI, desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var', }, { setup: () => { vi.stubEnv('GEMINI_API_KEY', 'test-key'); }, expected: AuthType.USE_GEMINI, desc: 'from GEMINI_API_KEY env var', }, { setup: () => {}, expected: AuthType.LOGIN_WITH_GOOGLE, desc: 'defaults to Sign in with Google', }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); unmount(); }); }); describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); expect(mockedValidateAuthMethod).toHaveBeenCalledWith( AuthType.USE_GEMINI, props.settings, ); expect(props.onAuthError).toHaveBeenCalledWith('Invalid method'); expect(props.settings.setValue).not.toHaveBeenCalled(); unmount(); }); it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); expect(props.setAuthContext).toHaveBeenCalledWith({ requiresRestart: true, }); unmount(); }); it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthContext).toHaveBeenCalledWith({}); unmount(); }); it('skips API key dialog on initial setup if env var is present', async () => { mockedValidateAuthMethod.mockReturnValue(null); vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); unmount(); }); it('skips API key dialog if env var is present but empty', async () => { mockedValidateAuthMethod.mockReturnValue(null); vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); unmount(); }); it('shows API key dialog on initial setup if no env var is present', async () => { mockedValidateAuthMethod.mockReturnValue(null); // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthState).toHaveBeenCalledWith( AuthState.AwaitingApiKeyInput, ); unmount(); }); it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => { mockedValidateAuthMethod.mockReturnValue(null); vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // Simulate that the user has already authenticated once props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); unmount(); }); it('exits process for Sign in with Google when browser is suppressed', async () => { vi.useFakeTimers(); const exitSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); await vi.runAllTimersAsync(); }); expect(mockedRunExitCleanup).toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); exitSpy.mockRestore(); logSpy.mockRestore(); vi.useRealTimers(); unmount(); }); }); it('displays authError when provided', async () => { props.authError = 'Something went wrong'; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); unmount(); }); describe('useKeypress', () => { it.each([ { desc: 'does nothing on escape if authError is present', setup: () => { props.authError = 'Some error'; }, expectations: (p: typeof props) => { expect(p.onAuthError).not.toHaveBeenCalled(); expect(p.setAuthState).not.toHaveBeenCalled(); }, }, { desc: 'calls onAuthError on escape if no auth method is set', setup: () => { props.settings.merged.security.auth.selectedType = undefined; }, expectations: (p: typeof props) => { expect(p.onAuthError).toHaveBeenCalledWith( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', ); }, }, { desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set', setup: () => { props.settings.merged.security.auth.selectedType = AuthType.USE_GEMINI; }, expectations: (p: typeof props) => { expect(p.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); expect(p.settings.setValue).not.toHaveBeenCalled(); }, }, ])('$desc', async ({ setup, expectations }) => { setup(); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); unmount(); }); }); describe('Snapshots', () => { it('renders correctly with default props', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); }); }); ================================================ FILE: packages/cli/src/ui/auth/AuthDialog.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { SettingScope, type LoadableSettingScope, type LoadedSettings, } from '../../config/settings.js'; import { AuthType, clearCachedCredentialFile, type Config, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { AuthState } from '../types.js'; import { validateAuthMethodWithSettings } from './useAuth.js'; import { relaunchApp } from '../../utils/processUtils.js'; interface AuthDialogProps { config: Config; settings: LoadedSettings; setAuthState: (state: AuthState) => void; authError: string | null; onAuthError: (error: string | null) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; } export function AuthDialog({ config, settings, setAuthState, authError, onAuthError, setAuthContext, }: AuthDialogProps): React.JSX.Element { const [exiting, setExiting] = useState(false); let items = [ { label: 'Sign in with Google', value: AuthType.LOGIN_WITH_GOOGLE, key: AuthType.LOGIN_WITH_GOOGLE, }, ...(process.env['CLOUD_SHELL'] === 'true' ? [ { label: 'Use Cloud Shell user credentials', value: AuthType.COMPUTE_ADC, key: AuthType.COMPUTE_ADC, }, ] : process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true' ? [ { label: 'Use metadata server application default credentials', value: AuthType.COMPUTE_ADC, key: AuthType.COMPUTE_ADC, }, ] : []), { label: 'Use Gemini API Key', value: AuthType.USE_GEMINI, key: AuthType.USE_GEMINI, }, { label: 'Vertex AI', value: AuthType.USE_VERTEX_AI, key: AuthType.USE_VERTEX_AI, }, ]; if (settings.merged.security.auth.enforcedType) { items = items.filter( (item) => item.value === settings.merged.security.auth.enforcedType, ); } let defaultAuthType = null; const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE']; if ( defaultAuthTypeEnv && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType) ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion defaultAuthType = defaultAuthTypeEnv as AuthType; } let initialAuthIndex = items.findIndex((item) => { if (settings.merged.security.auth.selectedType) { return item.value === settings.merged.security.auth.selectedType; } if (defaultAuthType) { return item.value === defaultAuthType; } if (process.env['GEMINI_API_KEY']) { return item.value === AuthType.USE_GEMINI; } return item.value === AuthType.LOGIN_WITH_GOOGLE; }); if (settings.merged.security.auth.enforcedType) { initialAuthIndex = 0; } const onSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (exiting) { return; } if (authType) { if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { setAuthContext({}); } await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); if ( authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { setExiting(true); setTimeout(relaunchApp, 100); return; } if (authType === AuthType.USE_GEMINI) { if (process.env['GEMINI_API_KEY'] !== undefined) { setAuthState(AuthState.Unauthenticated); return; } else { setAuthState(AuthState.AwaitingApiKeyInput); return; } } } setAuthState(AuthState.Unauthenticated); }, [settings, config, setAuthState, exiting, setAuthContext], ); const handleAuthSelect = (authMethod: AuthType) => { const error = validateAuthMethodWithSettings(authMethod, settings); if (error) { onAuthError(error); } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises onSelect(authMethod, SettingScope.User); } }; useKeypress( (key) => { if (key.name === 'escape') { // Prevent exit if there is an error message. // This means they user is not authenticated yet. if (authError) { return true; } if (settings.merged.security.auth.selectedType === undefined) { // Prevent exiting if no auth method is set onAuthError( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', ); return true; } // eslint-disable-next-line @typescript-eslint/no-floating-promises onSelect(undefined, SettingScope.User); return true; } return false; }, { isActive: true }, ); if (exiting) { return ( Logging in with Google... Restarting Gemini CLI to continue. ); } return ( ? Get started How would you like to authenticate for this project? { onAuthError(null); }} /> {authError && ( {authError} )} (Use Enter to select) Terms of Services and Privacy Notice for Gemini CLI {'https://geminicli.com/docs/resources/tos-privacy/'} ); } ================================================ FILE: packages/cli/src/ui/auth/AuthInProgress.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from '../../test-utils/render.js'; import { act } from 'react'; import { AuthInProgress } from './AuthInProgress.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, debugLogger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, }; }); vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); vi.mock('../components/CliSpinner.js', () => ({ CliSpinner: () => '[Spinner]', })); describe('AuthInProgress', () => { const onTimeout = vi.fn(); beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); vi.mocked(debugLogger.error).mockImplementation((...args) => { if ( typeof args[0] === 'string' && args[0].includes('was not wrapped in act') ) { return; } }); }); afterEach(() => { vi.useRealTimers(); }); it('renders initial state with spinner', async () => { const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); unmount(); }); it('calls onTimeout when ESC is pressed', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { keypressHandler({ name: 'escape' } as unknown as Key); }); // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act await act(async () => { await waitUntilReady(); }); expect(onTimeout).toHaveBeenCalled(); unmount(); }); it('calls onTimeout when Ctrl+C is pressed', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { keypressHandler({ name: 'c', ctrl: true } as unknown as Key); }); await waitUntilReady(); expect(onTimeout).toHaveBeenCalled(); unmount(); }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); }); await waitUntilReady(); expect(onTimeout).toHaveBeenCalled(); expect(lastFrame()).toContain('Authentication timed out'); unmount(); }); it('clears timer on unmount', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); await act(async () => { unmount(); }); await act(async () => { vi.advanceTimersByTime(180000); }); expect(onTimeout).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/ui/auth/AuthInProgress.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { CliSpinner } from '../components/CliSpinner.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; interface AuthInProgressProps { onTimeout: () => void; } export function AuthInProgress({ onTimeout, }: AuthInProgressProps): React.JSX.Element { const [timedOut, setTimedOut] = useState(false); useKeypress( (key) => { if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { onTimeout(); } }, { isActive: true }, ); useEffect(() => { const timer = setTimeout(() => { setTimedOut(true); onTimeout(); }, 180000); return () => clearTimeout(timer); }, [onTimeout]); return ( {timedOut ? ( Authentication timed out. Please try again. ) : ( Waiting for authentication... (Press Esc or Ctrl+C to cancel) )} ); } ================================================ FILE: packages/cli/src/ui/auth/BannedAccountDialog.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { BannedAccountDialog } from './BannedAccountDialog.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { openBrowserSecurely, shouldLaunchBrowser, } from '@google/gemini-cli-core'; import { Text } from 'ink'; import { runExitCleanup } from '../../utils/cleanup.js'; import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, openBrowserSecurely: vi.fn(), shouldLaunchBrowser: vi.fn().mockReturnValue(true), }; }); vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: vi.fn().mockResolvedValue(undefined), })); vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); vi.mock('../components/shared/RadioButtonSelect.js', () => ({ RadioButtonSelect: vi.fn(({ items }) => ( <> {items.map((item: { value: string; label: string }) => ( {item.label} ))} )), })); const mockedRadioButtonSelect = RadioButtonSelect as Mock; const mockedUseKeypress = useKeypress as Mock; const mockedOpenBrowser = openBrowserSecurely as Mock; const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; const mockedRunExitCleanup = runExitCleanup as Mock; const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { message: 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', appealUrl: 'https://example.com/appeal', appealLinkText: 'Appeal Here', }; describe('BannedAccountDialog', () => { let onExit: Mock; let onChangeAuth: Mock; beforeEach(() => { vi.resetAllMocks(); mockedShouldLaunchBrowser.mockReturnValue(true); mockedOpenBrowser.mockResolvedValue(undefined); mockedRunExitCleanup.mockResolvedValue(undefined); onExit = vi.fn(); onChangeAuth = vi.fn(); }); it('renders the suspension message from accountSuspensionInfo', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Account Suspended'); expect(frame).toContain('violation of Terms of Service'); expect(frame).toContain('Escape to exit'); unmount(); }); it('renders menu options with appeal link text from response', async () => { const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(3); expect(items[0].label).toBe('Appeal Here'); expect(items[1].label).toBe('Change authentication'); expect(items[2].label).toBe('Exit'); unmount(); }); it('hides form option when no appealUrl is provided', async () => { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(2); expect(items[0].label).toBe('Change authentication'); expect(items[1].label).toBe('Exit'); unmount(); }); it('uses default label when appealLinkText is not provided', async () => { const infoWithoutLinkText: AccountSuspensionInfo = { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items[0].label).toBe('Open the Google Form'); unmount(); }); it('opens browser when appeal option is selected', async () => { const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('open_form'); expect(mockedOpenBrowser).toHaveBeenCalledWith( 'https://example.com/appeal', ); expect(onExit).not.toHaveBeenCalled(); unmount(); }); it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('open_form'); await waitFor(() => { expect(lastFrame()).toContain('Please open this URL in a browser'); }); expect(mockedOpenBrowser).not.toHaveBeenCalled(); unmount(); }); it('calls onExit when "Exit" is selected', async () => { const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('exit'); expect(mockedRunExitCleanup).toHaveBeenCalled(); expect(onExit).toHaveBeenCalled(); unmount(); }); it('calls onChangeAuth when "Change authentication" is selected', async () => { const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('change_auth'); expect(onChangeAuth).toHaveBeenCalled(); expect(onExit).not.toHaveBeenCalled(); unmount(); }); it('exits on escape key', async () => { const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; const result = keypressHandler({ name: 'escape' }); expect(result).toBe(true); unmount(); }); it('renders snapshot correctly', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/auth/BannedAccountDialog.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { openBrowserSecurely, shouldLaunchBrowser, } from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; interface BannedAccountDialogProps { accountSuspensionInfo: AccountSuspensionInfo; onExit: () => void; onChangeAuth: () => void; } export function BannedAccountDialog({ accountSuspensionInfo, onExit, onChangeAuth, }: BannedAccountDialogProps): React.JSX.Element { const [errorMessage, setErrorMessage] = useState(null); const appealUrl = accountSuspensionInfo.appealUrl; const appealLinkText = accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; const items = useMemo(() => { const menuItems = []; if (appealUrl) { menuItems.push({ label: appealLinkText, value: 'open_form' as const, key: 'open_form', }); } menuItems.push( { label: 'Change authentication', value: 'change_auth' as const, key: 'change_auth', }, { label: 'Exit', value: 'exit' as const, key: 'exit', }, ); return menuItems; }, [appealUrl, appealLinkText]); useKeypress( (key) => { if (key.name === 'escape') { void handleExit(); return true; } return false; }, { isActive: true }, ); const handleExit = useCallback(async () => { await runExitCleanup(); onExit(); }, [onExit]); const handleSelect = useCallback( async (choice: string) => { if (choice === 'open_form' && appealUrl) { if (!shouldLaunchBrowser()) { setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); return; } try { await openBrowserSecurely(appealUrl); } catch { setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); } } else if (choice === 'change_auth') { onChangeAuth(); } else { await handleExit(); } }, [handleExit, onChangeAuth, appealUrl], ); return ( Error: Account Suspended {accountSuspensionInfo.message} {appealUrl && ( <> Appeal URL: [{appealUrl}] )} {errorMessage && ( {errorMessage} )} void handleSelect(choice)} /> Escape to exit ); } ================================================ FILE: packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE, _resetRelaunchStateForTesting, } from '../../utils/processUtils.js'; import { type Config } from '@google/gemini-cli-core'; // Mocks vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: vi.fn(), })); const mockedUseKeypress = useKeypress as Mock; const mockedRunExitCleanup = runExitCleanup as Mock; describe('LoginWithGoogleRestartDialog', () => { const onDismiss = vi.fn(); const exitSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); const mockConfig = { getRemoteAdminSettings: vi.fn(), } as unknown as Config; beforeEach(() => { vi.clearAllMocks(); exitSpy.mockClear(); vi.useRealTimers(); _resetRelaunchStateForTesting(); }); it('renders correctly', async () => { const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('calls onDismiss when escape is pressed', async () => { const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape', shift: false, ctrl: false, cmd: false, sequence: '\u001b', }); expect(onDismiss).toHaveBeenCalledTimes(1); unmount(); }); it.each(['r', 'R'])( 'calls runExitCleanup and process.exit when %s is pressed', async (keyName) => { vi.useFakeTimers(); const { waitUntilReady, unmount } = render( , ); await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: keyName, shift: false, ctrl: false, cmd: false, sequence: keyName, }); // Advance timers to trigger the setTimeout callback await vi.runAllTimersAsync(); expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); vi.useRealTimers(); unmount(); }, ); }); ================================================ FILE: packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type Config } from '@google/gemini-cli-core'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { relaunchApp } from '../../utils/processUtils.js'; interface LoginWithGoogleRestartDialogProps { onDismiss: () => void; config: Config; } export const LoginWithGoogleRestartDialog = ({ onDismiss, config, }: LoginWithGoogleRestartDialogProps) => { useKeypress( (key) => { if (key.name === 'escape') { onDismiss(); return true; } else if (key.name === 'r' || key.name === 'R') { setTimeout(async () => { if (process.send) { const remoteSettings = config.getRemoteAdminSettings(); if (remoteSettings) { process.send({ type: 'admin-settings-update', settings: remoteSettings, }); } } await relaunchApp(); }, 100); return true; } return false; }, { isActive: true }, ); const message = "You've successfully signed in with Google. Gemini CLI needs to be restarted."; return ( {message} Press R to restart, or Esc to choose a different authentication method. ); }; ================================================ FILE: packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ApiAuthDialog > renders correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ Enter Gemini API Key │ │ │ │ Please enter your Gemini API key. It will be securely stored in your system keychain. │ │ You can get an API key from https://aistudio.google.com/app/apikey │ │ │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ Paste your API key here │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; ================================================ FILE: packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ? Get started │ │ │ │ How would you like to authenticate for this project? │ │ │ │ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ │ │ │ Something went wrong │ │ │ │ (Use Enter to select) │ │ │ │ Terms of Services and Privacy Notice for Gemini CLI │ │ │ │ https://geminicli.com/docs/resources/tos-privacy/ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ? Get started │ │ │ │ How would you like to authenticate for this project? │ │ │ │ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ │ │ │ (Use Enter to select) │ │ │ │ Terms of Services and Privacy Notice for Gemini CLI │ │ │ │ https://geminicli.com/docs/resources/tos-privacy/ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ? Get started │ │ │ │ How would you like to authenticate for this project? │ │ │ │ (selected) Use Gemini API Key │ │ │ │ (Use Enter to select) │ │ │ │ Terms of Services and Privacy Notice for Gemini CLI │ │ │ │ https://geminicli.com/docs/resources/tos-privacy/ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; ================================================ FILE: packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` " Error: Account Suspended This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product. Appeal URL: [https://example.com/appeal] Appeal HereChange authenticationExit Escape to exit " `; ================================================ FILE: packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart, │ │ or Esc to choose a different authentication method. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; ================================================ FILE: packages/cli/src/ui/auth/useAuth.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import { renderHook } from '../../test-utils/render.js'; import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; import { AuthType, type Config, ProjectIdRequiredError, } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { waitFor } from '../../test-utils/async.js'; // Mock dependencies const mockLoadApiKey = vi.fn(); const mockValidateAuthMethod = vi.fn(); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadApiKey: () => mockLoadApiKey(), }; }); vi.mock('../../config/auth.js', () => ({ validateAuthMethod: (authType: AuthType) => mockValidateAuthMethod(authType), })); describe('useAuth', () => { beforeEach(() => { vi.resetAllMocks(); delete process.env['GEMINI_API_KEY']; delete process.env['GEMINI_DEFAULT_AUTH_TYPE']; }); afterEach(() => { vi.restoreAllMocks(); }); describe('validateAuthMethodWithSettings', () => { it('should return error if auth type is enforced and does not match', () => { const settings = { merged: { security: { auth: { enforcedType: AuthType.LOGIN_WITH_GOOGLE, }, }, }, } as LoadedSettings; const error = validateAuthMethodWithSettings( AuthType.USE_GEMINI, settings, ); expect(error).toContain('Authentication is enforced to be oauth'); }); it('should return null if useExternal is true', () => { const settings = { merged: { security: { auth: { useExternal: true, }, }, }, } as LoadedSettings; const error = validateAuthMethodWithSettings( AuthType.LOGIN_WITH_GOOGLE, settings, ); expect(error).toBeNull(); }); it('should return null if authType is USE_GEMINI', () => { const settings = { merged: { security: { auth: {}, }, }, } as LoadedSettings; const error = validateAuthMethodWithSettings( AuthType.USE_GEMINI, settings, ); expect(error).toBeNull(); }); it('should call validateAuthMethod for other auth types', () => { const settings = { merged: { security: { auth: {}, }, }, } as LoadedSettings; mockValidateAuthMethod.mockReturnValue('Validation Error'); const error = validateAuthMethodWithSettings( AuthType.LOGIN_WITH_GOOGLE, settings, ); expect(error).toBe('Validation Error'); expect(mockValidateAuthMethod).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); }); describe('useAuthCommand', () => { const mockConfig = { refreshAuth: vi.fn(), } as unknown as Config; const createSettings = (selectedType?: AuthType) => ({ merged: { security: { auth: { selectedType, }, }, }, }) as LoadedSettings; it('should initialize with Unauthenticated state', async () => { const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); expect(result.current.authState).toBe(AuthState.Unauthenticated); await waitFor(() => { expect(result.current.authState).toBe(AuthState.Authenticated); }); }); it('should set error if no auth type is selected and no env key', async () => { const { result } = renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); await waitFor(() => { expect(result.current.authError).toBe( 'No authentication method selected.', ); expect(result.current.authState).toBe(AuthState.Updating); }); }); it('should set error if no auth type is selected but env key exists', async () => { process.env['GEMINI_API_KEY'] = 'env-key'; const { result } = renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); await waitFor(() => { expect(result.current.authError).toContain( 'Existing API key detected (GEMINI_API_KEY)', ); expect(result.current.authState).toBe(AuthState.Updating); }); }); it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { mockLoadApiKey.mockResolvedValue(null); const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); await waitFor(() => { expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); }); it('should authenticate if USE_GEMINI and key is found', async () => { mockLoadApiKey.mockResolvedValue('stored-key'); const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); await waitFor(() => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.USE_GEMINI, ); expect(result.current.authState).toBe(AuthState.Authenticated); expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); }); it('should authenticate if USE_GEMINI and env key is found', async () => { mockLoadApiKey.mockResolvedValue(null); process.env['GEMINI_API_KEY'] = 'env-key'; const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); await waitFor(() => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.USE_GEMINI, ); expect(result.current.authState).toBe(AuthState.Authenticated); expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); }); it('should prioritize env key over stored key when both are present', async () => { mockLoadApiKey.mockResolvedValue('stored-key'); process.env['GEMINI_API_KEY'] = 'env-key'; const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); await waitFor(() => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.USE_GEMINI, ); expect(result.current.authState).toBe(AuthState.Authenticated); // The environment key should take precedence expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); }); it('should set error if validation fails', async () => { mockValidateAuthMethod.mockReturnValue('Validation Failed'); const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); await waitFor(() => { expect(result.current.authError).toBe('Validation Failed'); expect(result.current.authState).toBe(AuthState.Updating); }); }); it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); await waitFor(() => { expect(result.current.authError).toContain( 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', ); expect(result.current.authState).toBe(AuthState.Updating); }); }); it('should authenticate successfully for valid auth type', async () => { const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); await waitFor(() => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); expect(result.current.authState).toBe(AuthState.Authenticated); expect(result.current.authError).toBeNull(); }); }); it('should handle refreshAuth failure', async () => { (mockConfig.refreshAuth as Mock).mockRejectedValue( new Error('Auth Failed'), ); const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); await waitFor(() => { expect(result.current.authError).toContain('Failed to sign in'); expect(result.current.authState).toBe(AuthState.Updating); }); }); it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { const projectIdError = new ProjectIdRequiredError(); (mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError); const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); await waitFor(() => { expect(result.current.authError).toBe( 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', ); expect(result.current.authError).not.toContain('Failed to login'); expect(result.current.authState).toBe(AuthState.Updating); }); }); }); }); ================================================ FILE: packages/cli/src/ui/auth/useAuth.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useState, useEffect, useCallback } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; import { AuthType, type Config, loadApiKey, debugLogger, isAccountSuspendedError, ProjectIdRequiredError, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import { validateAuthMethod } from '../../config/auth.js'; export function validateAuthMethodWithSettings( authType: AuthType, settings: LoadedSettings, ): string | null { const enforcedType = settings.merged.security.auth.enforcedType; if (enforcedType && enforcedType !== authType) { return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; } if (settings.merged.security.auth.useExternal) { return null; } // If using Gemini API key, we don't validate it here as we might need to prompt for it. if (authType === AuthType.USE_GEMINI) { return null; } return validateAuthMethod(authType); } import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; export const useAuthCommand = ( settings: LoadedSettings, config: Config, initialAuthError: string | null = null, initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, ) => { const [authState, setAuthState] = useState( initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); const [authError, setAuthError] = useState(initialAuthError); const [accountSuspensionInfo, setAccountSuspensionInfo] = useState(initialAccountSuspensionInfo); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); const onAuthError = useCallback( (error: string | null) => { setAuthError(error); if (error) { setAuthState(AuthState.Updating); } }, [setAuthError, setAuthState], ); const reloadApiKey = useCallback(async () => { const envKey = process.env['GEMINI_API_KEY']; if (envKey !== undefined) { setApiKeyDefaultValue(envKey); return envKey; } const storedKey = (await loadApiKey()) ?? ''; setApiKeyDefaultValue(storedKey); return storedKey; }, []); useEffect(() => { if (authState === AuthState.AwaitingApiKeyInput) { // eslint-disable-next-line @typescript-eslint/no-floating-promises reloadApiKey(); } }, [authState, reloadApiKey]); useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { if (authState !== AuthState.Unauthenticated) { return; } const authType = settings.merged.security.auth.selectedType; if (!authType) { if (process.env['GEMINI_API_KEY']) { onAuthError( 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.', ); } else { onAuthError('No authentication method selected.'); } return; } if (authType === AuthType.USE_GEMINI) { const key = await reloadApiKey(); // Use the unified function if (!key) { setAuthState(AuthState.AwaitingApiKeyInput); return; } } const error = validateAuthMethodWithSettings(authType, settings); if (error) { onAuthError(error); return; } const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE']; if ( defaultAuthType && // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion !Object.values(AuthType).includes(defaultAuthType as AuthType) ) { onAuthError( `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` + `Valid values are: ${Object.values(AuthType).join(', ')}.`, ); return; } try { await config.refreshAuth(authType); debugLogger.log(`Authenticated via "${authType}".`); setAuthError(null); setAuthState(AuthState.Authenticated); } catch (e) { const suspendedError = isAccountSuspendedError(e); if (suspendedError) { setAccountSuspensionInfo({ message: suspendedError.message, appealUrl: suspendedError.appealUrl, appealLinkText: suspendedError.appealLinkText, }); } else if (e instanceof ProjectIdRequiredError) { // OAuth succeeded but account setup requires project ID // Show the error message directly without "Failed to login" prefix onAuthError(getErrorMessage(e)); } else { onAuthError(`Failed to sign in. Message: ${getErrorMessage(e)}`); } } })(); }, [ settings, config, authState, setAuthState, setAuthError, onAuthError, reloadApiKey, ]); return { authState, setAuthState, authError, onAuthError, apiKeyDefaultValue, reloadApiKey, accountSuspensionInfo, setAccountSuspensionInfo, }; }; ================================================ FILE: packages/cli/src/ui/colors.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { themeManager } from './themes/theme-manager.js'; import type { ColorsTheme } from './themes/theme.js'; export const Colors: ColorsTheme = { get type() { return themeManager.getActiveTheme().colors.type; }, get Foreground() { return themeManager.getActiveTheme().colors.Foreground; }, get Background() { return themeManager.getColors().Background; }, get LightBlue() { return themeManager.getActiveTheme().colors.LightBlue; }, get AccentBlue() { return themeManager.getActiveTheme().colors.AccentBlue; }, get AccentPurple() { return themeManager.getActiveTheme().colors.AccentPurple; }, get AccentCyan() { return themeManager.getActiveTheme().colors.AccentCyan; }, get AccentGreen() { return themeManager.getActiveTheme().colors.AccentGreen; }, get AccentYellow() { return themeManager.getActiveTheme().colors.AccentYellow; }, get AccentRed() { return themeManager.getActiveTheme().colors.AccentRed; }, get DiffAdded() { return themeManager.getActiveTheme().colors.DiffAdded; }, get DiffRemoved() { return themeManager.getActiveTheme().colors.DiffRemoved; }, get Comment() { return themeManager.getActiveTheme().colors.Comment; }, get Gray() { return themeManager.getActiveTheme().colors.Gray; }, get DarkGray() { return themeManager.getColors().DarkGray; }, get InputBackground() { return themeManager.getColors().InputBackground; }, get MessageBackground() { return themeManager.getColors().MessageBackground; }, get GradientColors() { return themeManager.getActiveTheme().colors.GradientColors; }, }; ================================================ FILE: packages/cli/src/ui/commands/aboutCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { aboutCommand } from './aboutCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { IdeClient, getVersion } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, IdeClient: { getInstance: vi.fn().mockResolvedValue({ getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), }), }, UserAccountManager: vi.fn().mockImplementation(() => ({ getCachedGoogleAccount: vi.fn().mockReturnValue('test-email@example.com'), })), getVersion: vi.fn(), }; }); describe('aboutCommand', () => { let mockContext: CommandContext; const originalPlatform = process.platform; const originalEnv = { ...process.env }; beforeEach(() => { mockContext = createMockCommandContext({ services: { agentContext: { config: { getModel: vi.fn(), getIdeMode: vi.fn().mockReturnValue(true), getUserTierName: vi.fn().mockReturnValue(undefined), }, }, settings: { merged: { security: { auth: { selectedType: 'test-auth', }, }, }, }, }, ui: { addItem: vi.fn(), }, } as unknown as CommandContext); vi.mocked(getVersion).mockResolvedValue('test-version'); vi.spyOn( mockContext.services.agentContext!.config, 'getModel', ).mockReturnValue('test-model'); process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; Object.defineProperty(process, 'platform', { value: 'test-os', }); }); afterEach(() => { vi.unstubAllEnvs(); Object.defineProperty(process, 'platform', { value: originalPlatform, }); process.env = originalEnv; vi.clearAllMocks(); }); it('should have the correct name and description', () => { expect(aboutCommand.name).toBe('about'); expect(aboutCommand.description).toBe('Show version info'); }); it('should call addItem with all version info', async () => { process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ABOUT, cliVersion: 'test-version', osVersion: 'test-os', sandboxEnv: 'no sandbox', modelVersion: 'test-model', selectedAuthType: 'test-auth', gcpProject: 'test-gcp-project', ideClient: 'test-ide', userEmail: 'test-email@example.com', tier: undefined, }); }); it('should show the correct sandbox environment variable', async () => { process.env['SANDBOX'] = 'gemini-sandbox'; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ sandboxEnv: 'gemini-sandbox', }), ); }); it('should show sandbox-exec profile when applicable', async () => { process.env['SANDBOX'] = 'sandbox-exec'; process.env['SEATBELT_PROFILE'] = 'test-profile'; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ sandboxEnv: 'sandbox-exec (test-profile)', }), ); }); it('should not show ide client when it is not detected', async () => { vi.mocked(IdeClient.getInstance).mockResolvedValue({ getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined), } as unknown as IdeClient); process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ABOUT, cliVersion: 'test-version', osVersion: 'test-os', sandboxEnv: 'no sandbox', modelVersion: 'test-model', selectedAuthType: 'test-auth', gcpProject: 'test-gcp-project', ideClient: '', }), ); }); it('should display the tier when getUserTierName returns a value', async () => { vi.mocked( mockContext.services.agentContext!.config.getUserTierName, ).mockReturnValue('Enterprise Tier'); if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ tier: 'Enterprise Tier', }), ); }); }); ================================================ FILE: packages/cli/src/ui/commands/aboutCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type CommandContext, type SlashCommand, } from './types.js'; import process from 'node:process'; import { MessageType, type HistoryItemAbout } from '../types.js'; import { IdeClient, UserAccountManager, debugLogger, getVersion, } from '@google/gemini-cli-core'; export const aboutCommand: SlashCommand = { name: 'about', description: 'Show version info', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: async (context) => { const osVersion = process.platform; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { sandboxEnv = process.env['SANDBOX']; } else if (process.env['SANDBOX'] === 'sandbox-exec') { sandboxEnv = `sandbox-exec (${ process.env['SEATBELT_PROFILE'] || 'unknown' })`; } const modelVersion = context.services.agentContext?.config.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; const ideClient = await getIdeClientName(context); const userAccountManager = new UserAccountManager(); const cachedAccount = userAccountManager.getCachedGoogleAccount(); debugLogger.log('AboutCommand: Retrieved cached Google account', { cachedAccount, }); const userEmail = cachedAccount ?? undefined; const tier = context.services.agentContext?.config.getUserTierName(); const aboutItem: Omit = { type: MessageType.ABOUT, cliVersion, osVersion, sandboxEnv, modelVersion, selectedAuthType, gcpProject, ideClient, userEmail, tier, }; context.ui.addItem(aboutItem); }, }; async function getIdeClientName(context: CommandContext) { if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); return ideClient?.getDetectedIdeDisplayName() ?? ''; } ================================================ FILE: packages/cli/src/ui/commands/agentsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { agentsCommand } from './agentsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { Config } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; vi.mock('../../utils/agentSettings.js', () => ({ enableAgent: vi.fn(), disableAgent: vi.fn(), })); vi.mock('../../utils/agentUtils.js', () => ({ renderAgentActionFeedback: vi.fn(), })); describe('agentsCommand', () => { let mockContext: ReturnType; let mockConfig: { getAgentRegistry: ReturnType; config: Config; }; beforeEach(() => { vi.clearAllMocks(); mockConfig = { getAgentRegistry: vi.fn().mockReturnValue({ getAllDefinitions: vi.fn().mockReturnValue([]), getAllAgentNames: vi.fn().mockReturnValue([]), reload: vi.fn(), }), get config() { return this as unknown as Config; }, }; mockContext = createMockCommandContext({ services: { agentContext: mockConfig as unknown as Config, settings: { workspace: { path: '/mock/path' }, merged: { agents: { overrides: {} } }, } as unknown as LoadedSettings, }, }); }); it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const result = await agentsCommand.action!(contextWithoutConfig, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should show an error if agent registry is not available', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); const result = await agentsCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Agent registry not found.', }); }); it('should call addItem with correct agents list', async () => { const mockAgents = [ { name: 'agent1', displayName: 'Agent One', description: 'desc1', kind: 'local', }, { name: 'agent2', displayName: undefined, description: 'desc2', kind: 'remote', }, ]; mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents); await agentsCommand.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.AGENTS_LIST, agents: mockAgents, }), ); }); it('should reload the agent registry when reload subcommand is called', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ reload: reloadSpy, }); const reloadCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'reload', ); expect(reloadCommand).toBeDefined(); const result = await reloadCommand!.action!(mockContext, ''); expect(reloadSpy).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Reloading agent registry...', }), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Agents reloaded successfully', }); }); it('should show an error if agent registry is not available during reload', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); const reloadCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'reload', ); const result = await reloadCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Agent registry not found.', }); }); it('should enable an agent successfully', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getAllAgentNames: vi.fn().mockReturnValue([]), reload: reloadSpy, }); // Add agent to disabled overrides so validation passes mockContext.services.settings.merged.agents.overrides['test-agent'] = { enabled: false, }; vi.mocked(enableAgent).mockReturnValue({ status: 'success', agentName: 'test-agent', action: 'enable', modifiedScopes: [], alreadyInStateScopes: [], }); vi.mocked(renderAgentActionFeedback).mockReturnValue('Enabled test-agent.'); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', ); expect(enableCommand).toBeDefined(); const result = await enableCommand!.action!(mockContext, 'test-agent'); expect(enableAgent).toHaveBeenCalledWith( mockContext.services.settings, 'test-agent', ); expect(reloadSpy).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Enabling test-agent...', }), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Enabled test-agent.', }); }); it('should handle no-op when enabling an agent', async () => { mockConfig .getAgentRegistry() .getAllAgentNames.mockReturnValue(['test-agent']); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', ); const result = await enableCommand!.action!(mockContext, 'test-agent'); expect(enableAgent).not.toHaveBeenCalled(); expect(mockContext.ui.addItem).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: "Agent 'test-agent' is already enabled.", }); }); it('should show usage error if no agent name provided for enable', async () => { const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', ); const result = await enableCommand!.action!(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /agents enable ', }); }); it('should show an error if config is not available for enable', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null }, }); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', ); const result = await enableCommand!.action!(contextWithoutConfig, 'test'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should disable an agent successfully', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getAllAgentNames: vi.fn().mockReturnValue(['test-agent']), reload: reloadSpy, }); vi.mocked(disableAgent).mockReturnValue({ status: 'success', agentName: 'test-agent', action: 'disable', modifiedScopes: [], alreadyInStateScopes: [], }); vi.mocked(renderAgentActionFeedback).mockReturnValue( 'Disabled test-agent.', ); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', ); expect(disableCommand).toBeDefined(); const result = await disableCommand!.action!(mockContext, 'test-agent'); expect(disableAgent).toHaveBeenCalledWith( mockContext.services.settings, 'test-agent', expect.anything(), // Scope is derived in the command ); expect(reloadSpy).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Disabling test-agent...', }), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Disabled test-agent.', }); }); it('should show info message if agent is already disabled', async () => { mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); mockContext.services.settings.merged.agents.overrides['test-agent'] = { enabled: false, }; const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', ); const result = await disableCommand!.action!(mockContext, 'test-agent'); expect(disableAgent).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: "Agent 'test-agent' is already disabled.", }); }); it('should show error if agent is not found when disabling', async () => { mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', ); const result = await disableCommand!.action!(mockContext, 'test-agent'); expect(disableAgent).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', content: "Agent 'test-agent' not found.", }); }); it('should show usage error if no agent name provided for disable', async () => { const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', ); const result = await disableCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /agents disable ', }); }); it('should show an error if config is not available for disable', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null }, }); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', ); const result = await disableCommand!.action!(contextWithoutConfig, 'test'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); describe('config sub-command', () => { it('should return dialog action for a valid agent', async () => { const mockDefinition = { name: 'test-agent', displayName: 'Test Agent', description: 'test desc', kind: 'local', }; mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); expect(configCommand).toBeDefined(); const result = await configCommand!.action!(mockContext, 'test-agent'); expect(result).toEqual({ type: 'dialog', dialog: 'agentConfig', props: { name: 'test-agent', displayName: 'Test Agent', definition: mockDefinition, }, }); }); it('should use name as displayName if displayName is missing', async () => { const mockDefinition = { name: 'test-agent', description: 'test desc', kind: 'local', }; mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); const result = await configCommand!.action!(mockContext, 'test-agent'); expect(result).toEqual({ type: 'dialog', dialog: 'agentConfig', props: { name: 'test-agent', displayName: 'test-agent', // Falls back to name definition: mockDefinition, }, }); }); it('should show error if agent is not found', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getDiscoveredDefinition: vi.fn().mockReturnValue(undefined), }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); const result = await configCommand!.action!(mockContext, 'non-existent'); expect(result).toEqual({ type: 'message', messageType: 'error', content: "Agent 'non-existent' not found.", }); }); it('should show usage error if no agent name provided', async () => { const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); const result = await configCommand!.action!(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /agents config ', }); }); it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null }, }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); const result = await configCommand!.action!(contextWithoutConfig, 'test'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should provide completions for discovered agents', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ getAllDiscoveredAgentNames: vi .fn() .mockReturnValue(['agent1', 'agent2', 'other']), }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', ); expect(configCommand?.completion).toBeDefined(); const completions = await configCommand!.completion!(mockContext, 'age'); expect(completions).toEqual(['agent1', 'agent2']); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/agentsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { SlashCommand, CommandContext, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAgentsList } from '../types.js'; import { SettingScope } from '../../config/settings.js'; import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; const agentsListCommand: SlashCommand = { name: 'list', description: 'List available local and remote agents', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { const config = context.services.agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const agentRegistry = config.getAgentRegistry(); if (!agentRegistry) { return { type: 'message', messageType: 'error', content: 'Agent registry not found.', }; } const agents = agentRegistry.getAllDefinitions().map((def) => ({ name: def.name, displayName: def.displayName, description: def.description, kind: def.kind, })); const agentsListItem: HistoryItemAgentsList = { type: MessageType.AGENTS_LIST, agents, }; context.ui.addItem(agentsListItem); return; }, }; async function enableAction( context: CommandContext, args: string, ): Promise { const config = context.services.agentContext?.config; const { settings } = context.services; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const agentName = args.trim(); if (!agentName) { return { type: 'message', messageType: 'error', content: 'Usage: /agents enable ', }; } const agentRegistry = config.getAgentRegistry(); if (!agentRegistry) { return { type: 'message', messageType: 'error', content: 'Agent registry not found.', }; } const allAgents = agentRegistry.getAllAgentNames(); const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.enabled === false, ); if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) { return { type: 'message', messageType: 'info', content: `Agent '${agentName}' is already enabled.`, }; } if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) { return { type: 'message', messageType: 'error', content: `Agent '${agentName}' not found.`, }; } const result = enableAgent(settings, agentName); if (result.status === 'no-op') { return { type: 'message', messageType: 'info', content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), }; } context.ui.addItem({ type: MessageType.INFO, text: `Enabling ${agentName}...`, }); await agentRegistry.reload(); return { type: 'message', messageType: 'info', content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), }; } async function disableAction( context: CommandContext, args: string, ): Promise { const config = context.services.agentContext?.config; const { settings } = context.services; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const agentName = args.trim(); if (!agentName) { return { type: 'message', messageType: 'error', content: 'Usage: /agents disable ', }; } const agentRegistry = config.getAgentRegistry(); if (!agentRegistry) { return { type: 'message', messageType: 'error', content: 'Agent registry not found.', }; } const allAgents = agentRegistry.getAllAgentNames(); const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.enabled === false, ); if (disabledAgents.includes(agentName)) { return { type: 'message', messageType: 'info', content: `Agent '${agentName}' is already disabled.`, }; } if (!allAgents.includes(agentName)) { return { type: 'message', messageType: 'error', content: `Agent '${agentName}' not found.`, }; } const scope = context.services.settings.workspace.path ? SettingScope.Workspace : SettingScope.User; const result = disableAgent(settings, agentName, scope); if (result.status === 'no-op') { return { type: 'message', messageType: 'info', content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), }; } context.ui.addItem({ type: MessageType.INFO, text: `Disabling ${agentName}...`, }); await agentRegistry.reload(); return { type: 'message', messageType: 'info', content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), }; } async function configAction( context: CommandContext, args: string, ): Promise { const config = context.services.agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const agentName = args.trim(); if (!agentName) { return { type: 'message', messageType: 'error', content: 'Usage: /agents config ', }; } const agentRegistry = config.getAgentRegistry(); if (!agentRegistry) { return { type: 'message', messageType: 'error', content: 'Agent registry not found.', }; } const definition = agentRegistry.getDiscoveredDefinition(agentName); if (!definition) { return { type: 'message', messageType: 'error', content: `Agent '${agentName}' not found.`, }; } const displayName = definition.displayName || agentName; return { type: 'dialog', dialog: 'agentConfig', props: { name: agentName, displayName, definition, }, }; } function completeAgentsToEnable(context: CommandContext, partialArg: string) { const config = context.services.agentContext?.config; const { settings } = context.services; if (!config) return []; const overrides = settings.merged.agents.overrides; const disabledAgents = Object.entries(overrides) .filter(([_, override]) => override?.enabled === false) .map(([name]) => name); return disabledAgents.filter((name) => name.startsWith(partialArg)); } function completeAgentsToDisable(context: CommandContext, partialArg: string) { const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); const allAgents = agentRegistry ? agentRegistry.getAllAgentNames() : []; return allAgents.filter((name: string) => name.startsWith(partialArg)); } function completeAllAgents(context: CommandContext, partialArg: string) { const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? []; return allAgents.filter((name: string) => name.startsWith(partialArg)); } const enableCommand: SlashCommand = { name: 'enable', description: 'Enable a disabled agent', kind: CommandKind.BUILT_IN, autoExecute: false, action: enableAction, completion: completeAgentsToEnable, }; const disableCommand: SlashCommand = { name: 'disable', description: 'Disable an enabled agent', kind: CommandKind.BUILT_IN, autoExecute: false, action: disableAction, completion: completeAgentsToDisable, }; const configCommand: SlashCommand = { name: 'config', description: 'Configure an agent', kind: CommandKind.BUILT_IN, autoExecute: false, action: configAction, completion: completeAllAgents, }; const agentsReloadCommand: SlashCommand = { name: 'reload', altNames: ['refresh'], description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { const config = context.services.agentContext?.config; const agentRegistry = config?.getAgentRegistry(); if (!agentRegistry) { return { type: 'message', messageType: 'error', content: 'Agent registry not found.', }; } context.ui.addItem({ type: MessageType.INFO, text: 'Reloading agent registry...', }); await agentRegistry.reload(); return { type: 'message', messageType: 'info', content: 'Agents reloaded successfully', }; }, }; export const agentsCommand: SlashCommand = { name: 'agents', description: 'Manage agents', kind: CommandKind.BUILT_IN, subCommands: [ agentsListCommand, agentsReloadCommand, enableCommand, disableCommand, configCommand, ], action: async (context: CommandContext, args) => // Default to list if no subcommand is provided agentsListCommand.action!(context, args), }; ================================================ FILE: packages/cli/src/ui/commands/authCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; import type { GeminiClient } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, clearCachedCredentialFile: vi.fn().mockResolvedValue(undefined), }; }); describe('authCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext({ services: { agentContext: { geminiClient: { stripThoughtsFromHistory: vi.fn(), }, }, }, }); // Add setValue mock to settings mockContext.services.settings.setValue = vi.fn(); vi.clearAllMocks(); }); it('should have subcommands: signin and signout', () => { expect(authCommand.subCommands).toBeDefined(); expect(authCommand.subCommands).toHaveLength(2); expect(authCommand.subCommands?.[0]?.name).toBe('signin'); expect(authCommand.subCommands?.[0]?.altNames).toContain('login'); expect(authCommand.subCommands?.[1]?.name).toBe('signout'); expect(authCommand.subCommands?.[1]?.altNames).toContain('logout'); }); it('should return a dialog action to open the auth dialog when called with no args', () => { if (!authCommand.action) { throw new Error('The auth command must have an action.'); } const result = authCommand.action(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'auth', }); }); it('should have the correct name and description', () => { expect(authCommand.name).toBe('auth'); expect(authCommand.description).toBe('Manage authentication'); }); describe('auth signin subcommand', () => { it('should return auth dialog action', () => { const loginCommand = authCommand.subCommands?.[0]; expect(loginCommand?.name).toBe('signin'); const result = loginCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'auth' }); }); }); describe('auth signout subcommand', () => { it('should clear cached credentials', async () => { const logoutCommand = authCommand.subCommands?.[1]; expect(logoutCommand?.name).toBe('signout'); const { clearCachedCredentialFile } = await import( '@google/gemini-cli-core' ); await logoutCommand!.action!(mockContext, ''); expect(clearCachedCredentialFile).toHaveBeenCalledOnce(); }); it('should clear selectedAuthType setting', async () => { const logoutCommand = authCommand.subCommands?.[1]; await logoutCommand!.action!(mockContext, ''); expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( SettingScope.User, 'security.auth.selectedType', undefined, ); }); it('should strip thoughts from history', async () => { const logoutCommand = authCommand.subCommands?.[1]; const mockStripThoughts = vi.fn(); const mockClient = { stripThoughtsFromHistory: mockStripThoughts, } as unknown as GeminiClient; if (mockContext.services.agentContext?.config) { mockContext.services.agentContext.config.getGeminiClient = vi.fn( () => mockClient, ); } await logoutCommand!.action!(mockContext, ''); expect( mockContext.services.agentContext?.geminiClient .stripThoughtsFromHistory, ).toHaveBeenCalled(); }); it('should return logout action to signal explicit state change', async () => { const logoutCommand = authCommand.subCommands?.[1]; const result = await logoutCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'logout' }); }); it('should handle missing config gracefully', async () => { const logoutCommand = authCommand.subCommands?.[1]; mockContext.services.agentContext = null; const result = await logoutCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'logout' }); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/authCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { OpenDialogActionReturn, SlashCommand, LogoutActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { clearCachedCredentialFile } from '@google/gemini-cli-core'; import { SettingScope } from '../../config/settings.js'; const authLoginCommand: SlashCommand = { name: 'signin', altNames: ['login'], description: 'Sign in or change the authentication method', kind: CommandKind.BUILT_IN, autoExecute: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'auth', }), }; const authLogoutCommand: SlashCommand = { name: 'signout', altNames: ['logout'], description: 'Sign out and clear all cached credentials', kind: CommandKind.BUILT_IN, action: async (context, _args): Promise => { await clearCachedCredentialFile(); // Clear the selected auth type so user sees the auth selection menu context.services.settings.setValue( SettingScope.User, 'security.auth.selectedType', undefined, ); // Strip thoughts from history instead of clearing completely context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); // Return logout action to signal explicit state change return { type: 'logout', }; }, }; export const authCommand: SlashCommand = { name: 'auth', description: 'Manage authentication', kind: CommandKind.BUILT_IN, subCommands: [authLoginCommand, authLogoutCommand], action: (context, args) => // Default to login if no subcommand is provided authLoginCommand.action!(context, args), }; ================================================ FILE: packages/cli/src/ui/commands/bugCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import open from 'open'; import path from 'node:path'; import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { getVersion } from '@google/gemini-cli-core'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatBytes } from '../utils/formatters.js'; // Mock dependencies vi.mock('open'); vi.mock('../utils/formatters.js'); vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, exportHistoryToFile: vi.fn(), }; }); import { exportHistoryToFile } from '../utils/historyExportUtils.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, IdeClient: { getInstance: () => ({ getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'), }), }, sessionId: 'test-session-id', getVersion: vi.fn(), INITIAL_HISTORY_LENGTH: 1, debugLogger: { error: vi.fn(), log: vi.fn(), debug: vi.fn(), warn: vi.fn(), }, }; }); vi.mock('node:process', () => ({ default: { platform: 'test-platform', version: 'v20.0.0', // Keep other necessary process properties if needed by other parts of the code env: process.env, memoryUsage: () => ({ rss: 0 }), }, })); vi.mock('../utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { getTerminalName: vi.fn().mockReturnValue('Test Terminal'), getTerminalBackgroundColor: vi.fn().mockReturnValue('#000000'), isKittyProtocolEnabled: vi.fn().mockReturnValue(true), }, })); describe('bugCommand', () => { beforeEach(() => { vi.mocked(getVersion).mockResolvedValue('0.1.0'); vi.mocked(formatBytes).mockReturnValue('100 MB'); vi.stubEnv('SANDBOX', 'gemini-test'); vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); }); afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); vi.useRealTimers(); }); it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { config: { getModel: () => 'gemini-pro', getBugCommand: () => undefined, getIdeMode: () => true, getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), }, geminiClient: { getChat: () => ({ getHistory: () => [], }), }, }, }, }); if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A test bug'); const expectedInfo = ` * **CLI Version:** 0.1.0 * **Git Commit:** ${GIT_COMMIT_INFO} * **Session ID:** test-session-id * **Operating System:** test-platform v20.0.0 * **Sandbox Environment:** test * **Model Version:** gemini-pro * **Auth Type:** oauth-personal * **Memory Usage:** 100 MB * **Terminal Name:** Test Terminal * **Terminal Background:** #000000 * **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`; expect(open).toHaveBeenCalledWith(expectedUrl); }); it('should export chat history if available', async () => { const history = [ { role: 'user', parts: [{ text: 'hello' }] }, { role: 'model', parts: [{ text: 'hi' }] }, ]; const mockContext = createMockCommandContext({ services: { agentContext: { config: { getModel: () => 'gemini-pro', getBugCommand: () => undefined, getIdeMode: () => true, getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), storage: { getProjectTempDir: () => '/tmp/gemini', }, }, geminiClient: { getChat: () => ({ getHistory: () => history, }), }, }, }, }); if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'Bug with history'); const expectedPath = path.join( '/tmp/gemini', 'bug-report-history-1704067200000.json', ); expect(exportHistoryToFile).toHaveBeenCalledWith({ history, filePath: expectedPath, }); const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; const messageText = addItemCall[0].text; expect(messageText).toContain(expectedPath); expect(messageText).toContain('📄 **Chat History Exported**'); expect(messageText).toContain('Privacy Disclaimer:'); expect(messageText).not.toContain('additional-context='); expect(messageText).toContain('problem='); const reminder = '\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.'; expect(messageText).toContain(encodeURIComponent(reminder)); }); it('should use a custom URL template from config if provided', async () => { const customTemplate = 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; const mockContext = createMockCommandContext({ services: { agentContext: { config: { getModel: () => 'gemini-pro', getBugCommand: () => ({ urlTemplate: customTemplate }), getIdeMode: () => true, getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), }, geminiClient: { getChat: () => ({ getHistory: () => [], }), }, }, }, }); if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A custom bug'); const expectedInfo = ` * **CLI Version:** 0.1.0 * **Git Commit:** ${GIT_COMMIT_INFO} * **Session ID:** test-session-id * **Operating System:** test-platform v20.0.0 * **Sandbox Environment:** test * **Model Version:** gemini-pro * **Auth Type:** vertex-ai * **Memory Usage:** 100 MB * **Terminal Name:** Test Terminal * **Terminal Background:** #000000 * **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; const expectedUrl = customTemplate .replace('{title}', encodeURIComponent('A custom bug')) .replace('{info}', encodeURIComponent(expectedInfo)); expect(open).toHaveBeenCalledWith(expectedUrl); }); }); ================================================ FILE: packages/cli/src/ui/commands/bugCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import open from 'open'; import process from 'node:process'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; import { MessageType } from '../types.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatBytes } from '../utils/formatters.js'; import { IdeClient, sessionId, getVersion, INITIAL_HISTORY_LENGTH, debugLogger, } from '@google/gemini-cli-core'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; import path from 'node:path'; export const bugCommand: SlashCommand = { name: 'bug', description: 'Submit a bug report', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); const agentContext = context.services.agentContext; const config = agentContext?.config; const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { sandboxEnv = process.env['SANDBOX'].replace(/^gemini-(?:code-)?/, ''); } else if (process.env['SANDBOX'] === 'sandbox-exec') { sandboxEnv = `sandbox-exec (${ process.env['SEATBELT_PROFILE'] || 'unknown' })`; } const modelVersion = config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); const memoryUsage = formatBytes(process.memoryUsage().rss); const ideClient = await getIdeClientName(context); const terminalName = terminalCapabilityManager.getTerminalName() || 'Unknown'; const terminalBgColor = terminalCapabilityManager.getTerminalBackgroundColor() || 'Unknown'; const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled() ? 'Supported' : 'Unsupported'; const authType = config?.getContentGeneratorConfig()?.authType || 'Unknown'; let info = ` * **CLI Version:** ${cliVersion} * **Git Commit:** ${GIT_COMMIT_INFO} * **Session ID:** ${sessionId} * **Operating System:** ${osVersion} * **Sandbox Environment:** ${sandboxEnv} * **Model Version:** ${modelVersion} * **Auth Type:** ${authType} * **Memory Usage:** ${memoryUsage} * **Terminal Name:** ${terminalName} * **Terminal Background:** ${terminalBgColor} * **Kitty Keyboard Protocol:** ${kittyProtocol} `; if (ideClient) { info += `* **IDE Client:** ${ideClient}\n`; } const chat = agentContext?.geminiClient?.getChat(); const history = chat?.getHistory() || []; let historyFileMessage = ''; let problemValue = bugDescription; if (history.length > INITIAL_HISTORY_LENGTH) { const tempDir = config?.storage?.getProjectTempDir(); if (tempDir) { const historyFileName = `bug-report-history-${Date.now()}.json`; const historyFilePath = path.join(tempDir, historyFileName); try { await exportHistoryToFile({ history, filePath: historyFilePath }); historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`; problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); debugLogger.error( `Failed to export chat history for bug report: ${errorMessage}`, ); } } } let bugReportUrl = 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}'; const bugCommandSettings = config?.getBugCommand(); if (bugCommandSettings?.urlTemplate) { bugReportUrl = bugCommandSettings.urlTemplate; } bugReportUrl = bugReportUrl .replace('{title}', encodeURIComponent(bugDescription)) .replace('{info}', encodeURIComponent(info)) .replace('{problem}', encodeURIComponent(problemValue)); context.ui.addItem( { type: MessageType.INFO, text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`, }, Date.now(), ); try { await open(bugReportUrl); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); context.ui.addItem( { type: MessageType.ERROR, text: `Could not open URL in browser: ${errorMessage}`, }, Date.now(), ); } }, }; async function getIdeClientName(context: CommandContext) { if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); return ideClient.getDetectedIdeDisplayName() ?? ''; } ================================================ FILE: packages/cli/src/ui/commands/chatCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { SlashCommand, CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { Content } from '@google/genai'; import { AuthType, type GeminiClient } from '@google/gemini-cli-core'; import * as fsPromises from 'node:fs/promises'; import { chatCommand, debugCommand } from './chatCommand.js'; import { serializeHistoryToMarkdown, exportHistoryToFile, } from '../utils/historyExportUtils.js'; import type { Stats } from 'node:fs'; import type { HistoryItemWithoutId } from '../types.js'; import path from 'node:path'; vi.mock('fs/promises', () => ({ stat: vi.fn(), readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]), writeFile: vi.fn(), })); vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, exportHistoryToFile: vi.fn(), }; }); describe('chatCommand', () => { const mockFs = vi.mocked(fsPromises); const mockExport = vi.mocked(exportHistoryToFile); let mockContext: CommandContext; let mockGetChat: ReturnType; let mockSaveCheckpoint: ReturnType; let mockLoadCheckpoint: ReturnType; let mockDeleteCheckpoint: ReturnType; let mockGetHistory: ReturnType; const getSubCommand = ( name: 'list' | 'save' | 'resume' | 'delete' | 'share', ): SlashCommand => { const subCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === name, ); if (!subCommand) { throw new Error(`/chat ${name} command not found.`); } return subCommand; }; beforeEach(() => { mockGetHistory = vi.fn().mockReturnValue([]); mockGetChat = vi.fn().mockReturnValue({ getHistory: mockGetHistory, }); mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] }); mockDeleteCheckpoint = vi.fn().mockResolvedValue(true); mockContext = createMockCommandContext({ services: { agentContext: { config: { getProjectRoot: () => '/project/root', getContentGeneratorConfig: () => ({ authType: AuthType.LOGIN_WITH_GOOGLE, }), storage: { getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', }, }, geminiClient: { getChat: mockGetChat, } as unknown as GeminiClient, }, logger: { saveCheckpoint: mockSaveCheckpoint, loadCheckpoint: mockLoadCheckpoint, deleteCheckpoint: mockDeleteCheckpoint, initialize: vi.fn().mockResolvedValue(undefined), }, }, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('should have the correct main command definition', () => { expect(chatCommand.name).toBe('chat'); expect(chatCommand.description).toBe( 'Browse auto-saved conversations and manage chat checkpoints', ); expect(chatCommand.autoExecute).toBe(true); expect(chatCommand.subCommands).toHaveLength(6); }); describe('list subcommand', () => { let listCommand: SlashCommand; beforeEach(() => { listCommand = getSubCommand('list'); }); it('should add a chat_list item to the UI', async () => { const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; const date1 = new Date(); const date2 = new Date(date1.getTime() + 1000); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFs.readdir.mockResolvedValue(fakeFiles as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFs.stat.mockImplementation(async (path: any): Promise => { if (path.endsWith('test1.json')) { return { mtime: date1 } as Stats; } return { mtime: date2 } as Stats; }); await listCommand?.action?.(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: 'chat_list', chats: [ { name: 'test1', mtime: date1.toISOString(), }, { name: 'test2', mtime: date2.toISOString(), }, ], }); }); }); describe('save subcommand', () => { let saveCommand: SlashCommand; const tag = 'my-tag'; let mockCheckpointExists: ReturnType; beforeEach(() => { saveCommand = getSubCommand('save'); mockCheckpointExists = vi.fn().mockResolvedValue(false); mockContext.services.logger.checkpointExists = mockCheckpointExists; }); it('should return an error if tag is missing', async () => { const result = await saveCommand?.action?.(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume save ', }); }); it('should inform if conversation history is empty or only contains system context', async () => { mockGetHistory.mockReturnValue([]); let result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to save.', }); mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context for our chat' }] }, ]); result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to save.', }); mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context for our chat' }] }, { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] }, { role: 'user', parts: [{ text: 'Hello, how are you?' }] }, ]); result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${tag}.`, }); }); it('should return confirm_action if checkpoint already exists', async () => { mockCheckpointExists.mockResolvedValue(true); mockContext.invocation = { raw: `/chat save ${tag}`, name: 'save', args: tag, }; const result = await saveCommand?.action?.(mockContext, tag); expect(mockCheckpointExists).toHaveBeenCalledWith(tag); expect(mockSaveCheckpoint).not.toHaveBeenCalled(); expect(result).toMatchObject({ type: 'confirm_action', originalInvocation: { raw: `/chat save ${tag}` }, }); // Check that prompt is a React element expect(result).toHaveProperty('prompt'); }); it('should save the conversation if overwrite is confirmed', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'context for our chat' }] }, { role: 'user', parts: [{ text: 'hello' }] }, ]; mockGetHistory.mockReturnValue(history); mockContext.overwriteConfirmed = true; const result = await saveCommand?.action?.(mockContext, tag); expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check expect(mockSaveCheckpoint).toHaveBeenCalledWith( { history, authType: AuthType.LOGIN_WITH_GOOGLE }, tag, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${tag}.`, }); }); }); describe('resume subcommand', () => { const goodTag = 'good-tag'; const badTag = 'bad-tag'; let resumeCommand: SlashCommand; beforeEach(() => { resumeCommand = getSubCommand('resume'); }); it('should return an error if tag is missing', async () => { const result = await resumeCommand?.action?.(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume resume ', }); }); it('should inform if checkpoint is not found', async () => { mockLoadCheckpoint.mockResolvedValue({ history: [] }); const result = await resumeCommand?.action?.(mockContext, badTag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `No saved checkpoint found with tag: ${badTag}.`, }); }); it('should resume a conversation with matching authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation, authType: AuthType.LOGIN_WITH_GOOGLE, }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'load_history', history: [ { type: 'user', text: 'hello gemini' }, { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, }); }); it('should block resuming a conversation with mismatched authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation, authType: AuthType.USE_GEMINI, }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Cannot resume chat. It was saved with a different authentication method (${AuthType.USE_GEMINI}) than the current one (${AuthType.LOGIN_WITH_GOOGLE}).`, }); }); it('should resume a legacy conversation without authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'load_history', history: [ { type: 'user', text: 'hello gemini' }, { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, }); }); describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation( (async (_: string): Promise => ({ mtime: new Date(), }) as Stats) as unknown as typeof fsPromises.stat, ); const result = await resumeCommand?.completion?.(mockContext, 'a'); expect(result).toEqual(['alpha']); }); it('should suggest filenames sorted by modified time (newest first)', async () => { const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; const date = new Date(); mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation((async ( path: string, ): Promise => { if (path.endsWith('test1.json')) { return { mtime: date } as Stats; } return { mtime: new Date(date.getTime() + 1000) } as Stats; }) as unknown as typeof fsPromises.stat); const result = await resumeCommand?.completion?.(mockContext, ''); // Sort items by last modified time (newest first) expect(result).toEqual(['test2', 'test1']); }); }); }); describe('delete subcommand', () => { let deleteCommand: SlashCommand; const tag = 'my-tag'; beforeEach(() => { deleteCommand = getSubCommand('delete'); }); it('should return an error if tag is missing', async () => { const result = await deleteCommand?.action?.(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume delete ', }); }); it('should return an error if checkpoint is not found', async () => { mockDeleteCheckpoint.mockResolvedValue(false); const result = await deleteCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Error: No checkpoint found with tag '${tag}'.`, }); }); it('should delete the conversation', async () => { const result = await deleteCommand?.action?.(mockContext, tag); expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint '${tag}' has been deleted.`, }); }); describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation( (async (_: string): Promise => ({ mtime: new Date(), }) as Stats) as unknown as typeof fsPromises.stat, ); const result = await deleteCommand?.completion?.(mockContext, 'a'); expect(result).toEqual(['alpha']); }); }); }); describe('share subcommand', () => { let shareCommand: SlashCommand; const mockHistory = [ { role: 'user', parts: [{ text: 'context' }] }, { role: 'model', parts: [{ text: 'context response' }] }, { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there!' }] }, ]; beforeEach(() => { shareCommand = getSubCommand('share'); vi.spyOn(process, 'cwd').mockReturnValue( path.resolve('/usr/local/google/home/myuser/gemini-cli'), ); vi.spyOn(Date, 'now').mockReturnValue(1234567890); mockGetHistory.mockReturnValue(mockHistory); mockFs.writeFile.mockClear(); }); it('should default to a json file if no path is provided', async () => { const result = await shareCommand?.action?.(mockContext, ''); const expectedPath = path.join( process.cwd(), 'gemini-conversation-1234567890.json', ); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should share the conversation to a JSON file', async () => { const filePath = 'my-chat.json'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should share the conversation to a Markdown file', async () => { const filePath = 'my-chat.md'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should return an error for unsupported file extensions', async () => { const filePath = 'my-chat.txt'; const result = await shareCommand?.action?.(mockContext, filePath); expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Invalid file format. Only .md and .json are supported.', }); }); it('should inform if there is no conversation to share', async () => { mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context' }] }, ]); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to share.', }); }); it('should handle errors during file writing', async () => { const error = new Error('Permission denied'); mockExport.mockRejectedValue(error); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Error sharing conversation: ${error.message}`, }); }); it('should output valid JSON schema', async () => { const filePath = 'my-chat.json'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); }); it('should output correct markdown format', async () => { const filePath = 'my-chat.md'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); }); }); describe('serializeHistoryToMarkdown', () => { it('should correctly serialize chat history to Markdown with icons', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there!' }] }, { role: 'user', parts: [{ text: 'How are you?' }] }, ]; const expectedMarkdown = '## USER 🧑‍💻\n\nHello\n\n---\n\n' + '## MODEL ✨\n\nHi there!\n\n---\n\n' + '## USER 🧑‍💻\n\nHow are you?'; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should handle empty history', () => { const history: Content[] = []; const result = serializeHistoryToMarkdown(history); expect(result).toBe(''); }); it('should handle items with no text parts', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [] }, { role: 'user', parts: [{ text: 'How are you?' }] }, ]; const expectedMarkdown = `## USER 🧑‍💻 Hello --- ## MODEL ✨ --- ## USER 🧑‍💻 How are you?`; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should correctly serialize function calls and responses', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Please call a function.' }], }, { role: 'model', parts: [ { functionCall: { name: 'my-function', args: { arg1: 'value1' }, }, }, ], }, { role: 'user', parts: [ { functionResponse: { name: 'my-function', response: { result: 'success' }, }, }, ], }, ]; const expectedMarkdown = `## USER 🧑‍💻 Please call a function. --- ## MODEL ✨ **Tool Command**: \`\`\`json { "name": "my-function", "args": { "arg1": "value1" } } \`\`\` --- ## USER 🧑‍💻 **Tool Response**: \`\`\`json { "name": "my-function", "response": { "result": "success" } } \`\`\``; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should handle items with undefined role', () => { const history: Array> = [ { role: 'user', parts: [{ text: 'Hello' }] }, { parts: [{ text: 'Hi there!' }] }, ]; const expectedMarkdown = `## USER 🧑‍💻 Hello --- ## MODEL ✨ Hi there!`; const result = serializeHistoryToMarkdown(history as Content[]); expect(result).toBe(expectedMarkdown); }); describe('debug subcommand', () => { let mockGetLatestApiRequest: ReturnType; beforeEach(() => { mockGetLatestApiRequest = vi.fn(); if (!mockContext.services.agentContext!.config) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockContext.services.agentContext!.config as any) = {}; } mockContext.services.agentContext!.config.getLatestApiRequest = mockGetLatestApiRequest; vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); vi.spyOn(Date, 'now').mockReturnValue(1234567890); mockFs.writeFile.mockClear(); }); it('should return an error if no API request is found', async () => { mockGetLatestApiRequest.mockReturnValue(undefined); const result = await debugCommand.action?.(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'No recent API request found to export.', }); expect(mockFs.writeFile).not.toHaveBeenCalled(); }); it('should convert and write the API request to a json file', async () => { const mockRequest = { contents: [{ role: 'user', parts: [{ text: 'test' }] }], }; mockGetLatestApiRequest.mockReturnValue(mockRequest); const result = await debugCommand.action?.(mockContext, ''); const expectedFilename = 'gcli-request-1234567890.json'; const expectedPath = path.join('/project/root', expectedFilename); expect(mockFs.writeFile).toHaveBeenCalledWith( expectedPath, expect.stringContaining('"role": "user"'), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Debug API request saved to ${expectedFilename}`, }); }); it('should handle errors during file write', async () => { const mockRequest = { contents: [] }; mockGetLatestApiRequest.mockReturnValue(mockRequest); mockFs.writeFile.mockRejectedValue(new Error('Write failed')); const result = await debugCommand.action?.(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Error saving debug request: Write failed', }); }); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/chatCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fsPromises from 'node:fs/promises'; import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { CommandContext, SlashCommand, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { decodeTagName, type MessageActionReturn, INITIAL_HISTORY_LENGTH, } from '@google/gemini-cli-core'; import path from 'node:path'; import type { HistoryItemWithoutId, HistoryItemChatList, ChatDetail, } from '../types.js'; import { MessageType } from '../types.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; import { convertToRestPayload } from '@google/gemini-cli-core'; const CHECKPOINT_MENU_GROUP = 'checkpoints'; const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { const cfg = context.services.agentContext?.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; } try { const file_head = 'checkpoint-'; const file_tail = '.json'; const files = await fsPromises.readdir(geminiDir); const chatDetails: ChatDetail[] = []; for (const file of files) { if (file.startsWith(file_head) && file.endsWith(file_tail)) { const filePath = path.join(geminiDir, file); const stats = await fsPromises.stat(filePath); const tagName = file.slice(file_head.length, -file_tail.length); chatDetails.push({ name: decodeTagName(tagName), mtime: stats.mtime.toISOString(), }); } } chatDetails.sort((a, b) => mtSortDesc ? b.mtime.localeCompare(a.mtime) : a.mtime.localeCompare(b.mtime), ); return chatDetails; } catch (_err) { return []; } }; const listCommand: SlashCommand = { name: 'list', description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); const item: HistoryItemChatList = { type: MessageType.CHAT_LIST, chats: chatDetails, }; context.ui.addItem(item); }, }; const saveCommand: SlashCommand = { name: 'save', description: 'Save the current conversation as a checkpoint. Usage: /resume save ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume save ', }; } const { logger } = context.services; const config = context.services.agentContext?.config; await logger.initialize(); if (!context.overwriteConfirmed) { const exists = await logger.checkpointExists(tag); if (exists) { return { type: 'confirm_action', prompt: React.createElement( Text, null, 'A checkpoint with the tag ', React.createElement(Text, { color: theme.text.accent }, tag), ' already exists. Do you want to overwrite it?', ), originalInvocation: { raw: context.invocation?.raw || `/resume save ${tag}`, }, }; } } const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', messageType: 'error', content: 'No chat client available to save conversation.', }; } const history = chat.getHistory(); if (history.length > INITIAL_HISTORY_LENGTH) { const authType = config?.getContentGeneratorConfig()?.authType; await logger.saveCheckpoint({ history, authType }, tag); return { type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${decodeTagName( tag, )}.`, }; } else { return { type: 'message', messageType: 'info', content: 'No conversation found to save.', }; } }, }; const resumeCheckpointCommand: SlashCommand = { name: 'resume', altNames: ['load'], description: 'Resume a conversation from a checkpoint. Usage: /resume resume ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args) => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume resume ', }; } const { logger } = context.services; const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; if (conversation.length === 0) { return { type: 'message', messageType: 'info', content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, }; } const currentAuthType = config?.getContentGeneratorConfig()?.authType; if ( checkpoint.authType && currentAuthType && checkpoint.authType !== currentAuthType ) { return { type: 'message', messageType: 'error', content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`, }; } const rolemap: { [key: string]: MessageType } = { user: MessageType.USER, model: MessageType.GEMINI, }; const uiHistory: HistoryItemWithoutId[] = []; for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) { const text = item.parts ?.filter((m) => !!m.text) .map((m) => m.text) .join('') || ''; if (!text) { continue; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion uiHistory.push({ type: (item.role && rolemap[item.role]) || MessageType.GEMINI, text, } as HistoryItemWithoutId); } return { type: 'load_history', history: uiHistory, clientHistory: conversation, }; }, completion: async (context, partialArg) => { const chatDetails = await getSavedChatTags(context, true); return chatDetails .map((chat) => chat.name) .filter((name) => name.startsWith(partialArg)); }, }; const deleteCommand: SlashCommand = { name: 'delete', description: 'Delete a conversation checkpoint. Usage: /resume delete ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { return { type: 'message', messageType: 'error', content: 'Missing tag. Usage: /resume delete ', }; } const { logger } = context.services; await logger.initialize(); const deleted = await logger.deleteCheckpoint(tag); if (deleted) { return { type: 'message', messageType: 'info', content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, }; } else { return { type: 'message', messageType: 'error', content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, }; } }, completion: async (context, partialArg) => { const chatDetails = await getSavedChatTags(context, true); return chatDetails .map((chat) => chat.name) .filter((name) => name.startsWith(partialArg)); }, }; const shareCommand: SlashCommand = { name: 'share', description: 'Share the current conversation to a markdown or json file. Usage: /resume share ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { let filePathArg = args.trim(); if (!filePathArg) { filePathArg = `gemini-conversation-${Date.now()}.json`; } const filePath = path.resolve(filePathArg); const extension = path.extname(filePath); if (extension !== '.md' && extension !== '.json') { return { type: 'message', messageType: 'error', content: 'Invalid file format. Only .md and .json are supported.', }; } const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', messageType: 'error', content: 'No chat client available to share conversation.', }; } const history = chat.getHistory(); // An empty conversation has a hidden message that sets up the context for // the chat. Thus, to check whether a conversation has been started, we // can't check for length 0. if (history.length <= INITIAL_HISTORY_LENGTH) { return { type: 'message', messageType: 'info', content: 'No conversation found to share.', }; } try { await exportHistoryToFile({ history, filePath }); return { type: 'message', messageType: 'info', content: `Conversation shared to ${filePath}`, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { type: 'message', messageType: 'error', content: `Error sharing conversation: ${errorMessage}`, }; } }, }; export const debugCommand: SlashCommand = { name: 'debug', description: 'Export the most recent API request as a JSON payload', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { const req = context.services.agentContext?.config.getLatestApiRequest(); if (!req) { return { type: 'message', messageType: 'error', content: 'No recent API request found to export.', }; } const restPayload = convertToRestPayload(req); const filename = `gcli-request-${Date.now()}.json`; const filePath = path.join(process.cwd(), filename); try { await fsPromises.writeFile( filePath, JSON.stringify(restPayload, null, 2), ); return { type: 'message', messageType: 'info', content: `Debug API request saved to ${filename}`, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { type: 'message', messageType: 'error', content: `Error saving debug request: ${errorMessage}`, }; } }, }; export const checkpointSubCommands: SlashCommand[] = [ listCommand, saveCommand, resumeCheckpointCommand, deleteCommand, shareCommand, ]; const checkpointCompatibilityCommand: SlashCommand = { name: 'checkpoints', altNames: ['checkpoint'], description: 'Compatibility command for nested checkpoint operations', kind: CommandKind.BUILT_IN, hidden: true, autoExecute: false, subCommands: checkpointSubCommands, }; export const chatResumeSubCommands: SlashCommand[] = [ ...checkpointSubCommands.map((subCommand) => ({ ...subCommand, suggestionGroup: CHECKPOINT_MENU_GROUP, })), checkpointCompatibilityCommand, ]; export const chatCommand: SlashCommand = { name: 'chat', description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async () => ({ type: 'dialog', dialog: 'sessionBrowser', }), subCommands: chatResumeSubCommands, }; ================================================ FILE: packages/cli/src/ui/commands/clearCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { clearCommand } from './clearCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; // Mock the telemetry service vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, uiTelemetryService: { setLastPromptTokenCount: vi.fn(), clear: vi.fn(), }, }; }); import { uiTelemetryService, type GeminiClient } from '@google/gemini-cli-core'; describe('clearCommand', () => { let mockContext: CommandContext; let mockResetChat: ReturnType; let mockHintClear: ReturnType; beforeEach(() => { mockResetChat = vi.fn().mockResolvedValue(undefined); mockHintClear = vi.fn(); const mockGetChatRecordingService = vi.fn(); vi.clearAllMocks(); mockContext = createMockCommandContext({ services: { agentContext: { config: { getEnableHooks: vi.fn().mockReturnValue(false), setSessionId: vi.fn(), getMessageBus: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue({ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), }), injectionService: { clear: mockHintClear, }, }, geminiClient: { resetChat: mockResetChat, getChat: () => ({ getChatRecordingService: mockGetChatRecordingService, }), } as unknown as GeminiClient, }, }, }); }); it('should set debug message, reset chat, reset telemetry, clear hints, and clear UI when config is available', async () => { if (!clearCommand.action) { throw new Error('clearCommand must have an action.'); } await clearCommand.action(mockContext, ''); expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith( 'Clearing terminal and resetting chat.', ); expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1); expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockHintClear).toHaveBeenCalledTimes(1); expect(uiTelemetryService.clear).toHaveBeenCalled(); expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); // Check the order of operations. const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock .invocationCallOrder[0]; const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; const resetTelemetryOrder = (uiTelemetryService.clear as Mock).mock .invocationCallOrder[0]; const clearOrder = (mockContext.ui.clear as Mock).mock .invocationCallOrder[0]; expect(setDebugMessageOrder).toBeLessThan(resetChatOrder); expect(resetChatOrder).toBeLessThan(resetTelemetryOrder); expect(resetTelemetryOrder).toBeLessThan(clearOrder); }); it('should not attempt to reset chat if config service is not available', async () => { if (!clearCommand.action) { throw new Error('clearCommand must have an action.'); } const nullConfigContext = createMockCommandContext({ services: { agentContext: null, }, }); await clearCommand.action(nullConfigContext, ''); expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith( 'Clearing terminal.', ); expect(mockResetChat).not.toHaveBeenCalled(); expect(uiTelemetryService.clear).toHaveBeenCalled(); expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/cli/src/ui/commands/clearCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { uiTelemetryService, SessionEndReason, SessionStartSource, flushTelemetry, } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; import { randomUUID } from 'node:crypto'; export const clearCommand: SlashCommand = { name: 'clear', description: 'Clear the screen and conversation history', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args) => { const geminiClient = context.services.agentContext?.geminiClient; const config = context.services.agentContext?.config; // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); if (hookSystem) { await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); } // Reset user steering hints config?.injectionService.clear(); // Start a new conversation recording with a new session ID // We MUST do this before calling resetChat() so the new ChatRecordingService // initialized by GeminiChat picks up the new session ID. let newSessionId: string | undefined; if (config) { newSessionId = randomUUID(); config.setSessionId(newSessionId); } if (geminiClient) { context.ui.setDebugMessage('Clearing terminal and resetting chat.'); // If resetChat fails, the exception will propagate and halt the command, // which is the correct behavior to signal a failure to the user. await geminiClient.resetChat(); } else { context.ui.setDebugMessage('Clearing terminal.'); } // Fire SessionStart hook after clearing let result; if (hookSystem) { result = await hookSystem.fireSessionStartEvent(SessionStartSource.Clear); } // Give the event loop a chance to process any pending telemetry operations // This ensures logger.emit() calls have fully propagated to the BatchLogRecordProcessor await new Promise((resolve) => setImmediate(resolve)); // Flush telemetry to ensure hooks are written to disk immediately // This is critical for tests and environments with I/O latency if (config) { await flushTelemetry(config); } uiTelemetryService.clear(newSessionId); context.ui.clear(); if (result?.systemMessage) { context.ui.addItem( { type: MessageType.INFO, text: result.systemMessage, }, Date.now(), ); } }, }; ================================================ FILE: packages/cli/src/ui/commands/commandsCommand.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { commandsCommand } from './commandsCommand.js'; import { MessageType } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; describe('commandsCommand', () => { let context: CommandContext; beforeEach(() => { vi.clearAllMocks(); context = createMockCommandContext({ ui: { reloadCommands: vi.fn(), }, }); }); describe('default action', () => { it('should return an info message prompting subcommand usage', async () => { const result = await commandsCommand.action!(context, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Use "/commands reload" to reload custom command definitions from .toml files.', }); }); }); describe('reload', () => { it('should call reloadCommands and show a success message', async () => { const reloadCmd = commandsCommand.subCommands!.find( (s) => s.name === 'reload', )!; await reloadCmd.action!(context, ''); expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Custom commands reloaded successfully.', }), expect.any(Number), ); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/commandsCommand.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandContext, type SlashCommand, type SlashCommandActionReturn, CommandKind, } from './types.js'; import { MessageType, type HistoryItemError, type HistoryItemInfo, } from '../types.js'; /** * Action for the default `/commands` invocation. * Displays a message prompting the user to use a subcommand. */ async function listAction( _context: CommandContext, _args: string, ): Promise { return { type: 'message', messageType: 'info', content: 'Use "/commands reload" to reload custom command definitions from .toml files.', }; } /** * Action for `/commands reload`. * Triggers a full re-discovery and reload of all slash commands, including * user/project-level .toml files, MCP prompts, and extension commands. */ async function reloadAction( context: CommandContext, ): Promise { try { context.ui.reloadCommands(); context.ui.addItem( { type: MessageType.INFO, text: 'Custom commands reloaded successfully.', } as HistoryItemInfo, Date.now(), ); } catch (error) { context.ui.addItem( { type: MessageType.ERROR, text: `Failed to reload commands: ${error instanceof Error ? error.message : String(error)}`, } as HistoryItemError, Date.now(), ); } } export const commandsCommand: SlashCommand = { name: 'commands', description: 'Manage custom slash commands. Usage: /commands [reload]', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'reload', altNames: ['refresh'], description: 'Reload custom command definitions from .toml files. Usage: /commands reload', kind: CommandKind.BUILT_IN, autoExecute: true, action: reloadAction, }, ], action: listAction, }; ================================================ FILE: packages/cli/src/ui/commands/compressCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CompressionStatus, type ChatCompressionInfo, type GeminiClient, } from '@google/gemini-cli-core'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { compressCommand } from './compressCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; describe('compressCommand', () => { let context: ReturnType; let mockTryCompressChat: ReturnType; beforeEach(() => { mockTryCompressChat = vi.fn(); context = createMockCommandContext({ services: { agentContext: { geminiClient: { tryCompressChat: mockTryCompressChat, } as unknown as GeminiClient, }, }, }); }); it('should do nothing if a compression is already pending', async () => { context.ui.pendingItem = { type: MessageType.COMPRESSION, compression: { isPending: true, originalTokenCount: null, newTokenCount: null, compressionStatus: null, }, }; await compressCommand.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Already compressing, wait for previous request to complete', }), expect.any(Number), ); expect(context.ui.setPendingItem).not.toHaveBeenCalled(); expect(mockTryCompressChat).not.toHaveBeenCalled(); }); it('should set pending item, call tryCompressChat, and add result on success', async () => { const compressedResult: ChatCompressionInfo = { originalTokenCount: 200, compressionStatus: CompressionStatus.COMPRESSED, newTokenCount: 100, }; mockTryCompressChat.mockResolvedValue(compressedResult); await compressCommand.action!(context, ''); expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, { type: MessageType.COMPRESSION, compression: { isPending: true, compressionStatus: null, originalTokenCount: null, newTokenCount: null, }, }); expect(mockTryCompressChat).toHaveBeenCalledWith( expect.stringMatching(/^compress-\d+$/), true, ); expect(context.ui.addItem).toHaveBeenCalledWith( { type: MessageType.COMPRESSION, compression: { isPending: false, compressionStatus: CompressionStatus.COMPRESSED, originalTokenCount: 200, newTokenCount: 100, }, }, expect.any(Number), ); expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null); }); it('should add an error message if tryCompressChat returns falsy', async () => { mockTryCompressChat.mockResolvedValue(null); await compressCommand.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Failed to compress chat history.', }), expect.any(Number), ); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); it('should add an error message if tryCompressChat throws', async () => { const error = new Error('Compression failed'); mockTryCompressChat.mockRejectedValue(error); await compressCommand.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: `Failed to compress chat history: ${error.message}`, }), expect.any(Number), ); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); it('should clear the pending item in a finally block', async () => { mockTryCompressChat.mockRejectedValue(new Error('some error')); await compressCommand.action!(context, ''); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); describe('metadata', () => { it('should have the correct name and aliases', () => { expect(compressCommand.name).toBe('compress'); expect(compressCommand.altNames).toContain('summarize'); expect(compressCommand.altNames).toContain('compact'); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/compressCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { MessageType, type HistoryItemCompression } from '../types.js'; import { CommandKind, type SlashCommand } from './types.js'; export const compressCommand: SlashCommand = { name: 'compress', altNames: ['summarize', 'compact'], description: 'Compresses the context by replacing it with a summary', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { const { ui } = context; if (ui.pendingItem) { ui.addItem( { type: MessageType.ERROR, text: 'Already compressing, wait for previous request to complete', }, Date.now(), ); return; } const pendingMessage: HistoryItemCompression = { type: MessageType.COMPRESSION, compression: { isPending: true, originalTokenCount: null, newTokenCount: null, compressionStatus: null, }, }; try { ui.setPendingItem(pendingMessage); const promptId = `compress-${Date.now()}`; const compressed = await context.services.agentContext?.geminiClient?.tryCompressChat( promptId, true, ); if (compressed) { ui.addItem( { type: MessageType.COMPRESSION, compression: { isPending: false, originalTokenCount: compressed.originalTokenCount, newTokenCount: compressed.newTokenCount, compressionStatus: compressed.compressionStatus, }, } as HistoryItemCompression, Date.now(), ); } else { ui.addItem( { type: MessageType.ERROR, text: 'Failed to compress chat history.', }, Date.now(), ); } } catch (e) { ui.addItem( { type: MessageType.ERROR, text: `Failed to compress chat history: ${ e instanceof Error ? e.message : String(e) }`, }, Date.now(), ); } finally { ui.setPendingItem(null); } }, }; ================================================ FILE: packages/cli/src/ui/commands/copyCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { copyCommand } from './copyCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { copyToClipboard } from '../utils/commandUtils.js'; vi.mock('../utils/commandUtils.js', () => ({ copyToClipboard: vi.fn(), })); describe('copyCommand', () => { let mockContext: CommandContext; let mockCopyToClipboard: Mock; let mockGetChat: Mock; let mockGetHistory: Mock; beforeEach(() => { vi.clearAllMocks(); mockCopyToClipboard = vi.mocked(copyToClipboard); mockGetChat = vi.fn(); mockGetHistory = vi.fn(); mockContext = createMockCommandContext({ services: { agentContext: { geminiClient: { getChat: mockGetChat, }, }, }, }); mockGetChat.mockReturnValue({ getHistory: mockGetHistory, }); }); it('should return info message when no history is available', async () => { if (!copyCommand.action) throw new Error('Command has no action'); mockGetChat.mockReturnValue(undefined); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No output in history', }); expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); it('should return info message when history is empty', async () => { if (!copyCommand.action) throw new Error('Command has no action'); mockGetHistory.mockReturnValue([]); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No output in history', }); expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); it('should return info message when no AI messages are found in history', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithUserOnly = [ { role: 'user', parts: [{ text: 'Hello' }], }, ]; mockGetHistory.mockReturnValue(historyWithUserOnly); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No output in history', }); expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); it('should copy last AI message to clipboard successfully', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithAiMessage = [ { role: 'user', parts: [{ text: 'Hello' }], }, { role: 'model', parts: [{ text: 'Hi there! How can I help you?' }], }, ]; mockGetHistory.mockReturnValue(historyWithAiMessage); mockCopyToClipboard.mockResolvedValue(undefined); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Last output copied to the clipboard', }); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'Hi there! How can I help you?', expect.anything(), ); }); it('should handle multiple text parts in AI message', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithMultipleParts = [ { role: 'model', parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }], }, ]; mockGetHistory.mockReturnValue(historyWithMultipleParts); mockCopyToClipboard.mockResolvedValue(undefined); const result = await copyCommand.action(mockContext, ''); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'Part 1: Part 2: Part 3', expect.anything(), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Last output copied to the clipboard', }); }); it('should filter out non-text parts', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithMixedParts = [ { role: 'model', parts: [ { text: 'Text part' }, { image: 'base64data' }, // Non-text part { text: ' more text' }, ], }, ]; mockGetHistory.mockReturnValue(historyWithMixedParts); mockCopyToClipboard.mockResolvedValue(undefined); const result = await copyCommand.action(mockContext, ''); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'Text part more text', expect.anything(), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Last output copied to the clipboard', }); }); it('should get the last AI message when multiple AI messages exist', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithMultipleAiMessages = [ { role: 'model', parts: [{ text: 'First AI response' }], }, { role: 'user', parts: [{ text: 'User message' }], }, { role: 'model', parts: [{ text: 'Second AI response' }], }, ]; mockGetHistory.mockReturnValue(historyWithMultipleAiMessages); mockCopyToClipboard.mockResolvedValue(undefined); const result = await copyCommand.action(mockContext, ''); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'Second AI response', expect.anything(), ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Last output copied to the clipboard', }); }); it('should handle clipboard copy error', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithAiMessage = [ { role: 'model', parts: [{ text: 'AI response' }], }, ]; mockGetHistory.mockReturnValue(historyWithAiMessage); const clipboardError = new Error('Clipboard access denied'); mockCopyToClipboard.mockRejectedValue(clipboardError); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Failed to copy to the clipboard. ${clipboardError.message}`, }); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'AI response', expect.anything(), ); }); it('should handle non-Error clipboard errors', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithAiMessage = [ { role: 'model', parts: [{ text: 'AI response' }], }, ]; mockGetHistory.mockReturnValue(historyWithAiMessage); const rejectedValue = 'String error'; mockCopyToClipboard.mockRejectedValue(rejectedValue); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Failed to copy to the clipboard. ${rejectedValue}`, }); expect(mockCopyToClipboard).toHaveBeenCalledWith( 'AI response', expect.anything(), ); }); it('should return info message when no text parts found in AI message', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const historyWithEmptyParts = [ { role: 'model', parts: [{ image: 'base64data' }], // No text parts }, ]; mockGetHistory.mockReturnValue(historyWithEmptyParts); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Last AI output contains no text to copy.', }); expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); it('should handle unavailable config service', async () => { if (!copyCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ services: { agentContext: null }, }); const result = await copyCommand.action(nullConfigContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No output in history', }); expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/ui/commands/copyCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger } from '@google/gemini-cli-core'; import { copyToClipboard } from '../utils/commandUtils.js'; import { CommandKind, type SlashCommand, type SlashCommandActionReturn, } from './types.js'; export const copyCommand: SlashCommand = { name: 'copy', description: 'Copy the last result or code snippet to clipboard', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args): Promise => { const chat = context.services.agentContext?.geminiClient?.getChat(); const history = chat?.getHistory(); // Get the last message from the AI (model role) const lastAiMessage = history ? history.filter((item) => item.role === 'model').pop() : undefined; if (!lastAiMessage) { return { type: 'message', messageType: 'info', content: 'No output in history', }; } // Extract text from the parts const lastAiOutput = lastAiMessage.parts ?.filter((part) => part.text) .map((part) => part.text) .join(''); if (lastAiOutput) { try { const settings = context.services.settings.merged; await copyToClipboard(lastAiOutput, settings); return { type: 'message', messageType: 'info', content: 'Last output copied to the clipboard', }; } catch (error) { const message = error instanceof Error ? error.message : String(error); debugLogger.debug(message); return { type: 'message', messageType: 'error', content: `Failed to copy to the clipboard. ${message}`, }; } } else { return { type: 'message', messageType: 'info', content: 'Last AI output contains no text to copy.', }; } }, }; ================================================ FILE: packages/cli/src/ui/commands/corgiCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { corgiCommand } from './corgiCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('corgiCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); vi.spyOn(mockContext.ui, 'toggleCorgiMode'); }); it('should call the toggleCorgiMode function on the UI context', async () => { if (!corgiCommand.action) { throw new Error('The corgi command must have an action.'); } await corgiCommand.action(mockContext, ''); expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1); }); it('should have the correct name and description', () => { expect(corgiCommand.name).toBe('corgi'); expect(corgiCommand.description).toBe('Toggles corgi mode'); }); }); ================================================ FILE: packages/cli/src/ui/commands/corgiCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; export const corgiCommand: SlashCommand = { name: 'corgi', description: 'Toggles corgi mode', hidden: true, kind: CommandKind.BUILT_IN, autoExecute: true, action: (context, _args) => { context.ui.toggleCorgiMode(); }, }; ================================================ FILE: packages/cli/src/ui/commands/directoryCommand.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { directoryCommand } from './directoryCommand.js'; import { expandHomeDir, getDirectorySuggestions, } from '../utils/directoryUtils.js'; import type { Config, WorkspaceContext } from '@google/gemini-cli-core'; import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js'; import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; import { MessageType } from '../types.js'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as trustedFolders from '../../config/trustedFolders.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, realpathSync: vi.fn((p) => p), }; }); vi.mock('../utils/directoryUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getDirectorySuggestions: vi.fn(), }; }); describe('directoryCommand', () => { let mockContext: CommandContext; let mockConfig: Config; let mockWorkspaceContext: WorkspaceContext; const addCommand = directoryCommand.subCommands?.find( (c) => c.name === 'add', ); const showCommand = directoryCommand.subCommands?.find( (c) => c.name === 'show', ); beforeEach(() => { mockWorkspaceContext = { targetDir: path.resolve('/test/dir'), addDirectory: vi.fn(), addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }), getDirectories: vi .fn() .mockReturnValue([ path.resolve('/home/user/project1'), path.resolve('/home/user/project2'), ]), } as unknown as WorkspaceContext; mockConfig = { getWorkspaceContext: () => mockWorkspaceContext, isRestrictiveSandbox: vi.fn().mockReturnValue(false), getGeminiClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), getChatRecordingService: vi.fn().mockReturnValue({ recordDirectories: vi.fn(), }), }), getWorkingDir: () => path.resolve('/test/dir'), shouldLoadMemoryFromIncludeDirectories: () => false, getDebugMode: () => false, getFileService: () => ({}), getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), get config() { return this; }, } as unknown as Config; mockContext = { services: { agentContext: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, security: { folderTrust: { enabled: false, }, }, }, }, }, ui: { addItem: vi.fn(), }, } as unknown as CommandContext; }); describe('show', () => { it('should display the list of directories', () => { if (!showCommand?.action) throw new Error('No action'); // eslint-disable-next-line @typescript-eslint/no-floating-promises showCommand.action(mockContext, ''); expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Current workspace directories:\n- ${path.resolve( '/home/user/project1', )}\n- ${path.resolve('/home/user/project2')}`, }), ); }); }); describe('add', () => { it('should show an error in a restrictive sandbox', async () => { if (!addCommand?.action) throw new Error('No action'); vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true); const result = await addCommand.action(mockContext, '/some/path'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', }); }); it('should show an error if no path is provided', () => { if (!addCommand?.action) throw new Error('No action'); // eslint-disable-next-line @typescript-eslint/no-floating-promises addCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Please provide at least one path to add.', }), ); }); it('should call addDirectory and show a success message for a single path', async () => { const newPath = path.resolve('/home/user/new-project'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [newPath], failed: [], }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, newPath); expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ newPath, ]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Successfully added directories:\n- ${newPath}`, }), ); }); it('should call addDirectory for each path and show a success message for multiple paths', async () => { const newPath1 = path.resolve('/home/user/new-project1'); const newPath2 = path.resolve('/home/user/new-project2'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [newPath1, newPath2], failed: [], }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, `${newPath1},${newPath2}`); expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ newPath1, newPath2, ]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, }), ); }); it('should show an error if addDirectory throws an exception', async () => { const error = new Error('Directory does not exist'); const newPath = path.resolve('/home/user/invalid-project'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [], failed: [{ path: newPath, error }], }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, newPath); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: `Error adding '${newPath}': ${error.message}`, }), ); }); it('should add directory directly when folder trust is disabled', async () => { if (!addCommand?.action) throw new Error('No action'); vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false); const newPath = path.resolve('/home/user/new-project'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [newPath], failed: [], }); await addCommand.action(mockContext, newPath); expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ newPath, ]); }); it('should show an info message for an already added directory', async () => { const existingPath = path.resolve('/home/user/project1'); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, existingPath); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `The following directories are already in the workspace:\n- ${existingPath}`, }), ); expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith( existingPath, ); }); it('should show an info message for an already added directory specified as a relative path', async () => { const existingPath = path.resolve('/home/user/project1'); const relativePath = './project1'; const absoluteRelativePath = path.resolve( path.resolve('/test/dir'), relativePath, ); vi.mocked(fs.realpathSync).mockImplementation((p) => { if (p === absoluteRelativePath) return existingPath; return p as string; }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, relativePath); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `The following directories are already in the workspace:\n- ${relativePath}`, }), ); }); it('should handle a mix of successful and failed additions', async () => { const validPath = path.resolve('/home/user/valid-project'); const invalidPath = path.resolve('/home/user/invalid-project'); const error = new Error('Directory does not exist'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [validPath], failed: [{ path: invalidPath, error }], }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, `${validPath},${invalidPath}`); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Successfully added directories:\n- ${validPath}`, }), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: `Error adding '${invalidPath}': ${error.message}`, }), ); }); describe('completion', () => { const completion = addCommand!.completion!; it('should return empty suggestions for an empty path', async () => { const results = await completion(mockContext, ''); expect(results).toEqual([]); }); it('should return empty suggestions for whitespace only path', async () => { const results = await completion(mockContext, ' '); expect(results).toEqual([]); }); it('should return suggestions for a single path', async () => { vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']); const results = await completion(mockContext, 'd'); expect(getDirectorySuggestions).toHaveBeenCalledWith('d'); expect(results).toEqual(['docs/', 'src/']); }); it('should return suggestions for multiple paths', async () => { vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); const results = await completion(mockContext, 'docs/,s'); expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); expect(results).toEqual(['docs/,src/']); }); it('should handle leading whitespace in suggestions', async () => { vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); const results = await completion(mockContext, 'docs/, s'); expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); expect(results).toEqual(['docs/, src/']); }); it('should filter out existing directories from suggestions', async () => { const existingPath = path.resolve(process.cwd(), 'existing'); vi.mocked(mockWorkspaceContext.getDirectories).mockReturnValue([ existingPath, ]); vi.mocked(getDirectorySuggestions).mockResolvedValue([ 'existing/', 'new/', ]); const results = await completion(mockContext, 'ex'); expect(results).toEqual(['new/']); }); }); }); describe('add with folder trust enabled', () => { let mockIsPathTrusted: Mock; beforeEach(() => { vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true); // isWorkspaceTrusted is no longer checked, so we don't need to mock it returning true mockIsPathTrusted = vi.fn(); const mockLoadedFolders = { isPathTrusted: mockIsPathTrusted, } as unknown as LoadedTrustedFolders; vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue( mockLoadedFolders, ); }); afterEach(() => { vi.restoreAllMocks(); }); it('should add a trusted directory', async () => { if (!addCommand?.action) throw new Error('No action'); mockIsPathTrusted.mockReturnValue(true); const newPath = path.resolve('/home/user/trusted-project'); vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ added: [newPath], failed: [], }); await addCommand.action(mockContext, newPath); expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ newPath, ]); }); it('should return a custom dialog for an explicitly untrusted directory (upgrade flow)', async () => { if (!addCommand?.action) throw new Error('No action'); mockIsPathTrusted.mockReturnValue(false); // DO_NOT_TRUST const newPath = path.resolve('/home/user/untrusted-project'); const result = await addCommand.action(mockContext, newPath); expect(result).toEqual( expect.objectContaining({ type: 'custom_dialog', component: expect.objectContaining({ type: expect.any(Function), // React component for MultiFolderTrustDialog }), }), ); if (!result) { throw new Error('Command did not return a result'); } const component = (result as OpenCustomDialogActionReturn) .component as React.ReactElement; expect(component.props.folders.includes(newPath)).toBeTruthy(); }); it('should return a custom dialog for a directory with undefined trust', async () => { if (!addCommand?.action) throw new Error('No action'); mockIsPathTrusted.mockReturnValue(undefined); const newPath = path.resolve('/home/user/undefined-trust-project'); const result = await addCommand.action(mockContext, newPath); expect(result).toEqual( expect.objectContaining({ type: 'custom_dialog', component: expect.objectContaining({ type: expect.any(Function), // React component for MultiFolderTrustDialog }), }), ); if (!result) { throw new Error('Command did not return a result'); } const component = (result as OpenCustomDialogActionReturn) .component as React.ReactElement; expect(component.props.folders.includes(newPath)).toBeTruthy(); }); it('should prompt for directory even if workspace is untrusted', async () => { if (!addCommand?.action) throw new Error('No action'); // Even if workspace is untrusted, we should still check directory trust vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ isTrusted: false, source: 'file', }); mockIsPathTrusted.mockReturnValue(undefined); const newPath = path.resolve('/home/user/new-project'); const result = await addCommand.action(mockContext, newPath); expect(result).toEqual( expect.objectContaining({ type: 'custom_dialog', }), ); }); }); it('should correctly expand a Windows-style home directory path', () => { const windowsPath = '%userprofile%\\Documents'; const expectedPath = path.win32.join(os.homedir(), 'Documents'); const result = expandHomeDir(windowsPath); expect(path.win32.normalize(result)).toBe( path.win32.normalize(expectedPath), ); }); }); ================================================ FILE: packages/cli/src/ui/commands/directoryCommand.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { isFolderTrustEnabled, loadTrustedFolders, } from '../../config/trustedFolders.js'; import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js'; import { CommandKind, type SlashCommand, type CommandContext, } from './types.js'; import { MessageType, type HistoryItem } from '../types.js'; import { refreshServerHierarchicalMemory, type Config, } from '@google/gemini-cli-core'; import { expandHomeDir, getDirectorySuggestions, batchAddDirectories, } from '../utils/directoryUtils.js'; import * as path from 'node:path'; import * as fs from 'node:fs'; async function finishAddingDirectories( config: Config, addItem: ( itemData: Omit, baseTimestamp?: number, ) => number, added: string[], errors: string[], ) { if (!config) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } if (added.length > 0) { try { if (config.shouldLoadMemoryFromIncludeDirectories()) { await refreshServerHierarchicalMemory(config); } addItem({ type: MessageType.INFO, text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, }); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion errors.push(`Error refreshing memory: ${(error as Error).message}`); } } if (added.length > 0) { const gemini = config.geminiClient; if (gemini) { await gemini.addDirectoryContext(); // Persist directories to session file for resume support const chatRecordingService = gemini.getChatRecordingService(); const workspaceContext = config.getWorkspaceContext(); chatRecordingService?.recordDirectories( workspaceContext.getDirectories(), ); } addItem({ type: MessageType.INFO, text: `Successfully added directories:\n- ${added.join('\n- ')}`, }); } if (errors.length > 0) { addItem({ type: MessageType.ERROR, text: errors.join('\n') }); } } export const directoryCommand: SlashCommand = { name: 'directory', altNames: ['dir'], description: 'Manage workspace directories', kind: CommandKind.BUILT_IN, subCommands: [ { name: 'add', description: 'Add directories to the workspace. Use comma to separate multiple paths', kind: CommandKind.BUILT_IN, autoExecute: false, showCompletionLoading: false, completion: async (context: CommandContext, partialArg: string) => { // Support multiple paths separated by commas const parts = partialArg.split(','); const lastPart = parts[parts.length - 1]; const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? ''; const trimmedLastPart = lastPart.trimStart(); if (trimmedLastPart === '') { return []; } const suggestions = await getDirectorySuggestions(trimmedLastPart); // Filter out existing directories let filteredSuggestions = suggestions; if (context.services.agentContext?.config) { const workspaceContext = context.services.agentContext.config.getWorkspaceContext(); const existingDirs = new Set( workspaceContext.getDirectories().map((dir) => path.resolve(dir)), ); filteredSuggestions = suggestions.filter((s) => { const expanded = expandHomeDir(s); const absolute = path.resolve(expanded); if (existingDirs.has(absolute)) { return false; } if ( absolute.endsWith(path.sep) && existingDirs.has(absolute.slice(0, -1)) ) { return false; } return true; }); } if (parts.length > 1) { const prefix = parts.slice(0, -1).join(',') + ','; return filteredSuggestions.map((s) => prefix + leadingWhitespace + s); } return filteredSuggestions.map((s) => leadingWhitespace + s); }, action: async (context: CommandContext, args: string) => { const { ui: { addItem }, services: { agentContext, settings }, } = context; const [...rest] = args.split(' '); if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } if (agentContext.config.isRestrictiveSandbox()) { return { type: 'message' as const, messageType: 'error' as const, content: 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', }; } const pathsToAdd = rest .join(' ') .split(',') .filter((p) => p); if (pathsToAdd.length === 0) { addItem({ type: MessageType.ERROR, text: 'Please provide at least one path to add.', }); return; } const added: string[] = []; const errors: string[] = []; const alreadyAdded: string[] = []; const workspaceContext = agentContext.config.getWorkspaceContext(); const currentWorkspaceDirs = workspaceContext.getDirectories(); const pathsToProcess: string[] = []; for (const pathToAdd of pathsToAdd) { const trimmedPath = pathToAdd.trim(); const expandedPath = expandHomeDir(trimmedPath); try { const absolutePath = path.resolve( workspaceContext.targetDir, expandedPath, ); const resolvedPath = fs.realpathSync(absolutePath); if (currentWorkspaceDirs.includes(resolvedPath)) { alreadyAdded.push(trimmedPath); continue; } } catch (_e) { // Path might not exist or be inaccessible. // We'll let batchAddDirectories handle it later. } pathsToProcess.push(trimmedPath); } if (alreadyAdded.length > 0) { addItem({ type: MessageType.INFO, text: `The following directories are already in the workspace:\n- ${alreadyAdded.join( '\n- ', )}`, }); } if (pathsToProcess.length === 0) { return; } if (isFolderTrustEnabled(settings.merged)) { const trustedFolders = loadTrustedFolders(); const dirsToConfirm: string[] = []; const trustedDirs: string[] = []; for (const pathToAdd of pathsToProcess) { const expandedPath = path.resolve(expandHomeDir(pathToAdd.trim())); const isTrusted = trustedFolders.isPathTrusted(expandedPath); // If explicitly trusted, add immediately. // If undefined or explicitly untrusted (DO_NOT_TRUST), prompt for confirmation. // This allows users to "upgrade" a DO_NOT_TRUST folder to trusted via the dialog. if (isTrusted === true) { trustedDirs.push(pathToAdd.trim()); } else { dirsToConfirm.push(pathToAdd.trim()); } } if (trustedDirs.length > 0) { const result = batchAddDirectories(workspaceContext, trustedDirs); added.push(...result.added); errors.push(...result.errors); } if (dirsToConfirm.length > 0) { return { type: 'custom_dialog', component: ( ), }; } } else { const result = batchAddDirectories(workspaceContext, pathsToProcess); added.push(...result.added); errors.push(...result.errors); } await finishAddingDirectories( agentContext.config, addItem, added, errors, ); return; }, }, { name: 'show', description: 'Show all directories in the workspace', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { const { ui: { addItem }, services: { agentContext }, } = context; if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } const workspaceContext = agentContext.config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); addItem({ type: MessageType.INFO, text: `Current workspace directories:\n${directoryList}`, }); }, }, ], }; ================================================ FILE: packages/cli/src/ui/commands/docsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import open from 'open'; import { docsCommand } from './docsCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; // Mock the 'open' library vi.mock('open', () => ({ default: vi.fn(), })); describe('docsCommand', () => { let mockContext: CommandContext; beforeEach(() => { // Create a fresh mock context before each test mockContext = createMockCommandContext(); // Reset the `open` mock vi.mocked(open).mockClear(); }); afterEach(() => { // Restore any stubbed environment variables vi.unstubAllEnvs(); }); it("should add an info message and call 'open' in a non-sandbox environment", async () => { if (!docsCommand.action) { throw new Error('docsCommand must have an action.'); } const docsUrl = 'https://goo.gle/gemini-cli-docs'; await docsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `Opening documentation in your browser: ${docsUrl}`, }, expect.any(Number), ); expect(open).toHaveBeenCalledWith(docsUrl); }); it('should only add an info message in a sandbox environment', async () => { if (!docsCommand.action) { throw new Error('docsCommand must have an action.'); } // Simulate a sandbox environment vi.stubEnv('SANDBOX', 'gemini-sandbox'); const docsUrl = 'https://goo.gle/gemini-cli-docs'; await docsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, }, expect.any(Number), ); // Ensure 'open' was not called in the sandbox expect(open).not.toHaveBeenCalled(); }); it("should not open browser for 'sandbox-exec'", async () => { if (!docsCommand.action) { throw new Error('docsCommand must have an action.'); } // Simulate the specific 'sandbox-exec' environment vi.stubEnv('SANDBOX', 'sandbox-exec'); const docsUrl = 'https://goo.gle/gemini-cli-docs'; await docsCommand.action(mockContext, ''); // The logic should fall through to the 'else' block expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `Opening documentation in your browser: ${docsUrl}`, }, expect.any(Number), ); // 'open' should be called in this specific sandbox case expect(open).toHaveBeenCalledWith(docsUrl); }); }); ================================================ FILE: packages/cli/src/ui/commands/docsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import open from 'open'; import process from 'node:process'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; import { MessageType } from '../types.js'; export const docsCommand: SlashCommand = { name: 'docs', description: 'Open full Gemini CLI documentation in your browser', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext): Promise => { const docsUrl = 'https://goo.gle/gemini-cli-docs'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { context.ui.addItem( { type: MessageType.INFO, text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, }, Date.now(), ); } else { context.ui.addItem( { type: MessageType.INFO, text: `Opening documentation in your browser: ${docsUrl}`, }, Date.now(), ); await open(docsUrl); } }, }; ================================================ FILE: packages/cli/src/ui/commands/editorCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; import { editorCommand } from './editorCommand.js'; // 1. Import the mock context utility import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('editorCommand', () => { it('should return a dialog action to open the editor dialog', () => { if (!editorCommand.action) { throw new Error('The editor command must have an action.'); } const mockContext = createMockCommandContext(); const result = editorCommand.action(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'editor', }); }); it('should have the correct name and description', () => { expect(editorCommand.name).toBe('editor'); expect(editorCommand.description).toBe('Set external editor preference'); }); }); ================================================ FILE: packages/cli/src/ui/commands/editorCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type OpenDialogActionReturn, type SlashCommand, } from './types.js'; export const editorCommand: SlashCommand = { name: 'editor', description: 'Set external editor preference', kind: CommandKind.BUILT_IN, autoExecute: true, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'editor', }), }; ================================================ FILE: packages/cli/src/ui/commands/extensionsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type ReactElement } from 'react'; import type { ExtensionLoader, GeminiCLIExtension, } from '@google/gemini-cli-core'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { completeExtensions, completeExtensionsAndScopes, extensionsCommand, } from './extensionsCommand.js'; import { ConfigExtensionDialog, type ConfigExtensionDialogProps, } from '../components/ConfigExtensionDialog.js'; import { ExtensionRegistryView, type ExtensionRegistryViewProps, } from '../components/views/ExtensionRegistryView.js'; import { type CommandContext, type SlashCommand } from './types.js'; import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction, } from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { stat } from 'node:fs/promises'; import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; import { waitFor } from '../../test-utils/async.js'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, inferInstallMetadata: vi.fn(), }; }); import open from 'open'; import type { Stats } from 'node:fs'; vi.mock('open', () => ({ default: vi.fn(), })); vi.mock('node:fs/promises', () => ({ stat: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ ExtensionSettingScope: { USER: 'user', WORKSPACE: 'workspace', }, getScopedEnvContents: vi.fn().mockResolvedValue({}), promptForSetting: vi.fn(), updateSetting: vi.fn(), })); vi.mock('prompts', () => ({ default: vi.fn(), })); vi.mock('../../config/extensions/update.js', () => ({ updateExtension: vi.fn(), checkForAllExtensionUpdates: vi.fn(), })); const mockDisableExtension = vi.fn(); const mockEnableExtension = vi.fn(); const mockInstallExtension = vi.fn(); const mockUninstallExtension = vi.fn(); const mockGetExtensions = vi.fn(); const inactiveExt: GeminiCLIExtension = { name: 'ext-one', id: 'ext-one-id', version: '1.0.0', isActive: false, // should suggest disabled extensions path: '/test/dir/ext-one', contextFiles: [], installMetadata: { type: 'git', autoUpdate: false, source: 'https://github.com/some/extension.git', }, }; const activeExt: GeminiCLIExtension = { name: 'ext-two', id: 'ext-two-id', version: '1.0.0', isActive: true, // should not suggest enabled extensions path: '/test/dir/ext-two', contextFiles: [], installMetadata: { type: 'git', autoUpdate: false, source: 'https://github.com/some/extension.git', }, }; const allExt: GeminiCLIExtension = { name: 'all-ext', id: 'all-ext-id', version: '1.0.0', isActive: true, path: '/test/dir/all-ext', contextFiles: [], installMetadata: { type: 'git', autoUpdate: false, source: 'https://github.com/some/extension.git', }, }; describe('extensionsCommand', () => { let mockContext: CommandContext; const mockDispatchExtensionState = vi.fn(); let mockExtensionLoader: unknown; let mockReloadSkills: MockedFunction<() => Promise>; let mockReloadAgents: MockedFunction<() => Promise>; beforeEach(() => { vi.resetAllMocks(); mockExtensionLoader = Object.create(ExtensionManager.prototype); Object.assign(mockExtensionLoader as object, { enableExtension: mockEnableExtension, disableExtension: mockDisableExtension, installOrUpdateExtension: mockInstallExtension, uninstallExtension: mockUninstallExtension, getExtensions: mockGetExtensions, loadExtensionConfig: vi.fn().mockResolvedValue({ name: 'test-ext', settings: [{ name: 'setting1', envVar: 'SETTING1' }], }), }); mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]); vi.mocked(open).mockClear(); mockReloadAgents = vi.fn().mockResolvedValue(undefined); mockReloadSkills = vi.fn().mockResolvedValue(undefined); mockContext = createMockCommandContext({ services: { agentContext: { config: { getExtensions: mockGetExtensions, getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), getWorkingDir: () => '/test/dir', reloadSkills: mockReloadSkills, getAgentRegistry: vi.fn().mockReturnValue({ reload: mockReloadAgents, }), }, }, }, ui: { dispatchExtensionStateUpdate: mockDispatchExtensionState, removeComponent: vi.fn(), }, }); }); afterEach(() => { // Restore any stubbed environment variables, similar to docsCommand.test.ts vi.unstubAllEnvs(); }); describe('list', () => { it('should add an EXTENSIONS_LIST item to the UI', async () => { const command = extensionsCommand(); if (!command.action) throw new Error('Action not defined'); await command.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); }); it('should show a message if no extensions are installed', async () => { mockGetExtensions.mockReturnValue([]); const command = extensionsCommand(); if (!command.action) throw new Error('Action not defined'); await command.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', }); }); }); describe('completeExtensions', () => { it.each([ { description: 'should return matching extension names', partialArg: 'ext', expected: ['ext-one', 'ext-two'], }, { description: 'should return --all when partialArg matches', partialArg: '--al', expected: ['--all'], }, { description: 'should return both extension names and --all when both match', partialArg: 'all', expected: ['--all', 'all-ext'], }, { description: 'should return an empty array if no matches', partialArg: 'nomatch', expected: [], }, { description: 'should suggest only disabled extension names for the enable command', partialArg: 'ext', expected: ['ext-one'], command: 'enable', }, { description: 'should suggest only enabled extension names for the disable command', partialArg: 'ext', expected: ['ext-two'], command: 'disable', }, ])('$description', async ({ partialArg, expected, command }) => { if (command) { mockContext.invocation!.name = command; } const suggestions = completeExtensions(mockContext, partialArg); expect(suggestions).toEqual(expected); }); }); describe('completeExtensionsAndScopes', () => { it('expands the list of suggestions with --scope args', () => { const suggestions = completeExtensionsAndScopes(mockContext, 'ext'); expect(suggestions).toEqual([ 'ext-one --scope user', 'ext-one --scope workspace', 'ext-one --scope session', 'ext-two --scope user', 'ext-two --scope workspace', 'ext-two --scope session', ]); }); }); describe('update', () => { const updateAction = extensionsCommand().subCommands?.find( (cmd) => cmd.name === 'update', )?.action; if (!updateAction) { throw new Error('Update action not found'); } it('should show usage if no args are provided', async () => { await updateAction(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions update |--all', }); }); it('should show a message if no extensions are installed', async () => { mockGetExtensions.mockReturnValue([]); await updateAction(mockContext, 'ext-one'); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', }); }); it('should inform user if there are no extensions to update with --all', async () => { mockDispatchExtensionState.mockImplementationOnce( async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([]); } }, ); await updateAction(mockContext, '--all'); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: 'No extensions to update.', }); }); it('should call setPendingItem and addItem in a finally block on success', async () => { mockDispatchExtensionState.mockImplementationOnce( async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { name: 'ext-one', originalVersion: '1.0.0', updatedVersion: '1.0.1', }, { name: 'ext-two', originalVersion: '2.0.0', updatedVersion: '2.0.1', }, ]); } }, ); await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); }); it('should call setPendingItem and addItem in a finally block on failure', async () => { mockDispatchExtensionState.mockImplementationOnce((_) => { throw new Error('Something went wrong'); }); await updateAction(mockContext, '--all'); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Something went wrong', }); }); it('should update a single extension by name', async () => { mockDispatchExtensionState.mockImplementationOnce( async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { name: 'ext-one', originalVersion: '1.0.0', updatedVersion: '1.0.1', }, ]); } }, ); await updateAction(mockContext, 'ext-one'); expect(mockDispatchExtensionState).toHaveBeenCalledWith({ type: 'SCHEDULE_UPDATE', payload: { all: false, names: ['ext-one'], onComplete: expect.any(Function), }, }); }); it('should update multiple extensions by name', async () => { mockDispatchExtensionState.mockImplementationOnce( async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { name: 'ext-one', originalVersion: '1.0.0', updatedVersion: '1.0.1', }, { name: 'ext-two', originalVersion: '1.0.0', updatedVersion: '1.0.1', }, ]); } }, ); await updateAction(mockContext, 'ext-one ext-two'); expect(mockDispatchExtensionState).toHaveBeenCalledWith({ type: 'SCHEDULE_UPDATE', payload: { all: false, names: ['ext-one', 'ext-two'], onComplete: expect.any(Function), }, }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.EXTENSIONS_LIST, extensions: expect.any(Array), }); }); }); describe('explore', () => { const exploreAction = extensionsCommand().subCommands?.find( (cmd) => cmd.name === 'explore', )?.action; if (!exploreAction) { throw new Error('Explore action not found'); } it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { mockContext.services.settings.merged.experimental.extensionRegistry = true; const result = await exploreAction(mockContext, ''); expect(result).toBeDefined(); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const component = result.component as ReactElement; expect(component.type).toBe(ExtensionRegistryView); expect(component.props.extensionManager).toBe(mockExtensionLoader); }); it('should handle onSelect and onClose in ExtensionRegistryView', async () => { mockContext.services.settings.merged.experimental.extensionRegistry = true; const result = await exploreAction(mockContext, ''); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const component = result.component as ReactElement; const extension = { extensionName: 'test-ext', url: 'https://github.com/test/ext.git', } as RegistryExtension; vi.mocked(inferInstallMetadata).mockResolvedValue({ source: extension.url, type: 'git', }); mockInstallExtension.mockResolvedValue({ name: extension.url }); // Call onSelect await component.props.onSelect?.(extension); await waitFor(() => { expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); expect(mockInstallExtension).toHaveBeenCalledWith( { source: extension.url, type: 'git', }, undefined, undefined, ); }); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); // Call onClose component.props.onClose?.(); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); }); it("should add an info message and call 'open' in a non-sandbox environment", async () => { // Ensure no special environment variables that would affect behavior vi.stubEnv('NODE_ENV', ''); vi.stubEnv('SANDBOX', ''); await exploreAction(mockContext, ''); const extensionsUrl = 'https://geminicli.com/extensions/'; expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Opening extensions page in your browser: ${extensionsUrl}`, }); expect(open).toHaveBeenCalledWith(extensionsUrl); }); it('should only add an info message in a sandbox environment', async () => { // Simulate a sandbox environment vi.stubEnv('NODE_ENV', ''); vi.stubEnv('SANDBOX', 'gemini-sandbox'); const extensionsUrl = 'https://geminicli.com/extensions/'; await exploreAction(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `View available extensions at ${extensionsUrl}`, }); // Ensure 'open' was not called in the sandbox expect(open).not.toHaveBeenCalled(); }); it('should add an info message and not call open in NODE_ENV test environment', async () => { vi.stubEnv('NODE_ENV', 'test'); vi.stubEnv('SANDBOX', ''); const extensionsUrl = 'https://geminicli.com/extensions/'; await exploreAction(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, }); // Ensure 'open' was not called in test environment expect(open).not.toHaveBeenCalled(); }); it('should handle errors when opening the browser', async () => { vi.stubEnv('NODE_ENV', ''); const extensionsUrl = 'https://geminicli.com/extensions/'; const errorMessage = 'Failed to open browser'; vi.mocked(open).mockRejectedValue(new Error(errorMessage)); await exploreAction(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, }); }); }); describe('when enableExtensionReloading is true', () => { it('should include enable, disable, install, link, and uninstall subcommands', () => { const command = extensionsCommand(true); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); expect(subCommandNames).toContain('install'); expect(subCommandNames).toContain('link'); expect(subCommandNames).toContain('uninstall'); }); }); describe('when enableExtensionReloading is false', () => { it('should not include enable, disable, install, link, and uninstall subcommands', () => { const command = extensionsCommand(false); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); expect(subCommandNames).not.toContain('install'); expect(subCommandNames).not.toContain('link'); expect(subCommandNames).not.toContain('uninstall'); }); }); describe('when enableExtensionReloading is not provided', () => { it('should not include enable, disable, install, link, and uninstall subcommands by default', () => { const command = extensionsCommand(); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); expect(subCommandNames).not.toContain('install'); expect(subCommandNames).not.toContain('link'); expect(subCommandNames).not.toContain('uninstall'); }); }); describe('install', () => { let installAction: SlashCommand['action']; beforeEach(() => { installAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'install', )?.action; expect(installAction).not.toBeNull(); mockContext.invocation!.name = 'install'; }); it('should show usage if no extension name is provided', async () => { await installAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions install ', }); expect(mockInstallExtension).not.toHaveBeenCalled(); }); it('should call installExtension and show success message', async () => { const packageName = 'test-extension-package'; vi.mocked(inferInstallMetadata).mockResolvedValue({ source: packageName, type: 'git', }); mockInstallExtension.mockResolvedValue({ name: packageName }); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); expect(mockInstallExtension).toHaveBeenCalledWith( { source: packageName, type: 'git', }, undefined, undefined, ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Installing extension from "${packageName}"...`, }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Extension "${packageName}" installed successfully.`, }); }); it('should show error message on installation failure', async () => { const packageName = 'failed-extension'; const errorMessage = 'install failed'; vi.mocked(inferInstallMetadata).mockResolvedValue({ source: packageName, type: 'git', }); mockInstallExtension.mockRejectedValue(new Error(errorMessage)); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); expect(mockInstallExtension).toHaveBeenCalledWith( { source: packageName, type: 'git', }, undefined, undefined, ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to install extension from "${packageName}": ${errorMessage}`, }); }); it('should show error message for invalid source', async () => { const invalidSource = 'a;b'; await installAction!(mockContext, invalidSource); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Invalid source: ${invalidSource}`, }); expect(mockInstallExtension).not.toHaveBeenCalled(); }); }); describe('link', () => { let linkAction: SlashCommand['action']; beforeEach(() => { linkAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'link', )?.action; expect(linkAction).not.toBeNull(); mockContext.invocation!.name = 'link'; }); it('should show usage if no extension is provided', async () => { await linkAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions link ', }); expect(mockInstallExtension).not.toHaveBeenCalled(); }); it('should call installExtension and show success message', async () => { const packageName = 'test-extension-package'; mockInstallExtension.mockResolvedValue({ name: packageName }); vi.mocked(stat).mockResolvedValue({ size: 100, } as Stats); await linkAction!(mockContext, packageName); expect(mockInstallExtension).toHaveBeenCalledWith({ source: packageName, type: 'link', }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Extension "${packageName}" linked successfully.`, }); }); it('should show error message on linking failure', async () => { const packageName = 'test-extension-package'; const errorMessage = 'link failed'; mockInstallExtension.mockRejectedValue(new Error(errorMessage)); vi.mocked(stat).mockResolvedValue({ size: 100, } as Stats); await linkAction!(mockContext, packageName); expect(mockInstallExtension).toHaveBeenCalledWith({ source: packageName, type: 'link', }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, }); }); it('should show error message for invalid source', async () => { const packageName = 'test-extension-package'; const errorMessage = 'invalid path'; vi.mocked(stat).mockRejectedValue(new Error(errorMessage)); await linkAction!(mockContext, packageName); expect(mockInstallExtension).not.toHaveBeenCalled(); }); }); describe('uninstall', () => { let uninstallAction: SlashCommand['action']; beforeEach(() => { uninstallAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'uninstall', )?.action; expect(uninstallAction).not.toBeNull(); mockContext.invocation!.name = 'uninstall'; }); it('should show usage if no extension name is provided', async () => { await uninstallAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions uninstall |--all', }); expect(mockUninstallExtension).not.toHaveBeenCalled(); }); it('should call uninstallExtension and show success message', async () => { const extensionName = 'test-extension'; await uninstallAction!(mockContext, extensionName); expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Uninstalling extension "${extensionName}"...`, }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Extension "${extensionName}" uninstalled successfully.`, }); }); it('should show error message on uninstallation failure', async () => { const extensionName = 'failed-extension'; const errorMessage = 'uninstall failed'; mockUninstallExtension.mockRejectedValue(new Error(errorMessage)); await uninstallAction!(mockContext, extensionName); expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, }); }); }); describe('enable', () => { let enableAction: SlashCommand['action']; beforeEach(() => { enableAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'enable', )?.action; expect(enableAction).not.toBeNull(); mockContext.invocation!.name = 'enable'; }); it('should show usage if no extension name is provided', async () => { await enableAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions enable [--scope=]', }); }); it('should call enableExtension with the provided scope', async () => { await enableAction!(mockContext, `${inactiveExt.name} --scope=user`); expect(mockEnableExtension).toHaveBeenCalledWith( inactiveExt.name, SettingScope.User, ); await enableAction!(mockContext, `${inactiveExt.name} --scope workspace`); expect(mockEnableExtension).toHaveBeenCalledWith( inactiveExt.name, SettingScope.Workspace, ); }); it('should support --all', async () => { mockGetExtensions.mockReturnValue([ inactiveExt, { ...inactiveExt, name: 'another-inactive-ext' }, ]); await enableAction!(mockContext, '--all --scope session'); expect(mockEnableExtension).toHaveBeenCalledWith( inactiveExt.name, SettingScope.Session, ); expect(mockEnableExtension).toHaveBeenCalledWith( 'another-inactive-ext', SettingScope.Session, ); }); }); describe('disable', () => { let disableAction: SlashCommand['action']; beforeEach(() => { disableAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'disable', )?.action; expect(disableAction).not.toBeNull(); mockContext.invocation!.name = 'disable'; }); it('should show usage if no extension name is provided', async () => { await disableAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Usage: /extensions disable [--scope=]', }); }); it('should call disableExtension with the provided scope', async () => { await disableAction!(mockContext, `${activeExt.name} --scope=user`); expect(mockDisableExtension).toHaveBeenCalledWith( activeExt.name, SettingScope.User, ); await disableAction!(mockContext, `${activeExt.name} --scope workspace`); expect(mockDisableExtension).toHaveBeenCalledWith( activeExt.name, SettingScope.Workspace, ); }); it('should support --all', async () => { mockGetExtensions.mockReturnValue([ activeExt, { ...activeExt, name: 'another-active-ext' }, ]); await disableAction!(mockContext, '--all --scope session'); expect(mockDisableExtension).toHaveBeenCalledWith( activeExt.name, SettingScope.Session, ); expect(mockDisableExtension).toHaveBeenCalledWith( 'another-active-ext', SettingScope.Session, ); }); }); describe('reload', () => { let restartAction: SlashCommand['action']; let mockRestartExtension: MockedFunction< typeof ExtensionLoader.prototype.restartExtension >; beforeEach(() => { restartAction = extensionsCommand().subCommands?.find( (c) => c.name === 'reload', )?.action; expect(restartAction).not.toBeNull(); mockRestartExtension = vi.fn(); mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: mockGetExtensions, restartExtension: mockRestartExtension, })); mockContext.invocation!.name = 'reload'; }); it('should show a message if no extensions are installed', async () => { mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: () => [], restartExtension: mockRestartExtension, })); await restartAction!(mockContext, '--all'); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', }); }); it('reloads all active extensions when --all is provided', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: true }, { name: 'ext3', isActive: false }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); await restartAction!(mockContext, '--all'); expect(mockRestartExtension).toHaveBeenCalledTimes(2); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Reloading 2 extensions...', }), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: '2 extensions reloaded successfully', }), ); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ type: 'RESTARTED', payload: { name: 'ext1' }, }); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ type: 'RESTARTED', payload: { name: 'ext2' }, }); expect(mockReloadSkills).toHaveBeenCalled(); expect(mockReloadAgents).toHaveBeenCalled(); }); it('handles errors during skill or agent reload', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills')); await restartAction!(mockContext, '--all'); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); expect(mockReloadSkills).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Failed to reload skills or agents: Failed to reload skills', }), ); }); it('reloads only specified active extensions', async () => { const mockExtensions = [ { name: 'ext1', isActive: false }, { name: 'ext2', isActive: true }, { name: 'ext3', isActive: true }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); await restartAction!(mockContext, 'ext1 ext3'); expect(mockRestartExtension).toHaveBeenCalledTimes(1); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ type: 'RESTARTED', payload: { name: 'ext3' }, }); }); it('shows an error if no extension loader is available', async () => { mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); await restartAction!(mockContext, '--all'); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: "Extensions are not yet loaded, can't restart yet", }), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); it('shows usage error for no arguments', async () => { await restartAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Usage: /extensions reload |--all', }), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); it('handles errors during extension reload', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); mockRestartExtension.mockRejectedValue(new Error('Failed to restart')); await restartAction!(mockContext, '--all'); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Failed to reload some extensions:\n ext1: Failed to restart', }), ); }); it('shows a warning if an extension is not found', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); await restartAction!(mockContext, 'ext1 ext2'); expect(mockRestartExtension).toHaveBeenCalledTimes(1); expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.WARNING, text: 'Extension(s) not found or not active: ext2', }), ); }); it('does not reload any extensions if none are found', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); await restartAction!(mockContext, 'ext2 ext3'); expect(mockRestartExtension).not.toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.WARNING, text: 'Extension(s) not found or not active: ext2, ext3', }), ); }); it('should suggest only enabled extension names for the reload command', async () => { mockContext.invocation!.name = 'reload'; const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: false }, ] as GeminiCLIExtension[]; mockGetExtensions.mockReturnValue(mockExtensions); const suggestions = completeExtensions(mockContext, 'ext'); expect(suggestions).toEqual(['ext1']); }); }); describe('config', () => { let configAction: SlashCommand['action']; beforeEach(async () => { configAction = extensionsCommand(true).subCommands?.find( (cmd) => cmd.name === 'config', )?.action; expect(configAction).not.toBeNull(); mockContext.invocation!.name = 'config'; const prompts = (await import('prompts')).default; vi.mocked(prompts).mockResolvedValue({ overwrite: true }); const { getScopedEnvContents } = await import( '../../config/extensions/extensionSettings.js' ); vi.mocked(getScopedEnvContents).mockResolvedValue({}); }); it('should return dialog to configure all extensions if no args provided', async () => { const result = await configAction!(mockContext, ''); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const dialogResult = result; const component = dialogResult.component as ReactElement; expect(component.type).toBe(ConfigExtensionDialog); expect(component.props.configureAll).toBe(true); expect(component.props.extensionManager).toBeDefined(); }); it('should return dialog to configure specific extension', async () => { const result = await configAction!(mockContext, 'ext-one'); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const dialogResult = result; const component = dialogResult.component as ReactElement; expect(component.type).toBe(ConfigExtensionDialog); expect(component.props.extensionName).toBe('ext-one'); expect(component.props.settingKey).toBeUndefined(); expect(component.props.configureAll).toBe(false); }); it('should return dialog to configure specific setting for an extension', async () => { const result = await configAction!(mockContext, 'ext-one SETTING1'); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const dialogResult = result; const component = dialogResult.component as ReactElement; expect(component.type).toBe(ConfigExtensionDialog); expect(component.props.extensionName).toBe('ext-one'); expect(component.props.settingKey).toBe('SETTING1'); expect(component.props.scope).toBe('user'); // Default scope }); it('should respect scope argument passed to dialog', async () => { const result = await configAction!( mockContext, 'ext-one SETTING1 --scope=workspace', ); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const dialogResult = result; const component = dialogResult.component as ReactElement; expect(component.props.scope).toBe('workspace'); }); it('should show error for invalid extension name', async () => { await configAction!(mockContext, '../invalid'); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Invalid extension name. Names cannot contain path separators or "..".', }); }); // "should inform if extension has no settings" - This check is now inside ConfigExtensionDialog logic. // We can test that we still return a dialog, and the dialog will handle logical checks via utils.ts // For unit testing extensionsCommand, we just ensure delegation. it('should return dialog even if extension has no settings (dialog handles logic)', async () => { const result = await configAction!(mockContext, 'ext-one'); if (result?.type !== 'custom_dialog') { throw new Error('Expected custom_dialog'); } const dialogResult = result; const component = dialogResult.component as ReactElement; expect(component.type).toBe(ConfigExtensionDialog); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/extensionsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger, listExtensions, getErrorMessage, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { emptyIcon, MessageType, type HistoryItemExtensionsList, type HistoryItemInfo, } from '../types.js'; import { type CommandContext, type SlashCommand, type SlashCommandActionReturn, CommandKind, } from './types.js'; import open from 'open'; import process from 'node:process'; import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; import { theme } from '../semantic-colors.js'; import { stat } from 'node:fs/promises'; import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { type ConfigLogger } from '../../commands/extensions/utils.js'; import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; import React from 'react'; function showMessageIfNoExtensions( context: CommandContext, extensions: unknown[], ): boolean { if (extensions.length === 0) { context.ui.addItem({ type: MessageType.INFO, text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', }); return true; } return false; } async function listAction(context: CommandContext) { const extensions = context.services.agentContext?.config ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { return; } const historyItem: HistoryItemExtensionsList = { type: MessageType.EXTENSIONS_LIST, extensions, }; context.ui.addItem(historyItem); } function updateAction(context: CommandContext, args: string): Promise { const updateArgs = args.split(' ').filter((value) => value.length > 0); const all = updateArgs.length === 1 && updateArgs[0] === '--all'; const names = all ? null : updateArgs; if (!all && names?.length === 0) { context.ui.addItem({ type: MessageType.ERROR, text: 'Usage: /extensions update |--all', }); return Promise.resolve(); } let resolveUpdateComplete: (updateInfo: ExtensionUpdateInfo[]) => void; const updateComplete = new Promise( (resolve) => (resolveUpdateComplete = resolve), ); const extensions = context.services.agentContext?.config ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { return Promise.resolve(); } const historyItem: HistoryItemExtensionsList = { type: MessageType.EXTENSIONS_LIST, extensions, }; // eslint-disable-next-line @typescript-eslint/no-floating-promises updateComplete.then((updateInfos) => { if (updateInfos.length === 0) { context.ui.addItem({ type: MessageType.INFO, text: 'No extensions to update.', }); } context.ui.addItem(historyItem); context.ui.setPendingItem(null); }); try { context.ui.setPendingItem(historyItem); context.ui.dispatchExtensionStateUpdate({ type: 'SCHEDULE_UPDATE', payload: { all, names, onComplete: (updateInfos) => { resolveUpdateComplete(updateInfos); }, }, }); if (names?.length) { const extensions = listExtensions(context.services.agentContext!.config); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, ); if (!extension) { context.ui.addItem({ type: MessageType.ERROR, text: `Extension ${name} not found.`, }); continue; } } } } catch (error) { resolveUpdateComplete!([]); context.ui.addItem({ type: MessageType.ERROR, text: getErrorMessage(error), }); } return updateComplete.then((_) => {}); } async function restartAction( context: CommandContext, args: string, ): Promise { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!extensionLoader) { context.ui.addItem({ type: MessageType.ERROR, text: "Extensions are not yet loaded, can't restart yet", }); return; } const extensions = extensionLoader.getExtensions(); if (showMessageIfNoExtensions(context, extensions)) { return; } const restartArgs = args.split(' ').filter((value) => value.length > 0); const all = restartArgs.length === 1 && restartArgs[0] === '--all'; const names = all ? null : restartArgs; if (!all && names?.length === 0) { context.ui.addItem({ type: MessageType.ERROR, text: 'Usage: /extensions reload |--all', }); return Promise.resolve(); } let extensionsToRestart = extensionLoader .getExtensions() .filter((extension) => extension.isActive); if (names) { extensionsToRestart = extensionsToRestart.filter((extension) => names.includes(extension.name), ); if (names.length !== extensionsToRestart.length) { const notFound = names.filter( (name) => !extensionsToRestart.some((extension) => extension.name === name), ); if (notFound.length > 0) { context.ui.addItem({ type: MessageType.WARNING, text: `Extension(s) not found or not active: ${notFound.join(', ')}`, }); } } } if (extensionsToRestart.length === 0) { // We will have logged a different message above already. return; } const s = extensionsToRestart.length > 1 ? 's' : ''; const reloadingMessage = { type: MessageType.INFO, text: `Reloading ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; context.ui.addItem(reloadingMessage); const results = await Promise.allSettled( extensionsToRestart.map(async (extension) => { if (extension.isActive) { await extensionLoader.restartExtension(extension); context.ui.dispatchExtensionStateUpdate({ type: 'RESTARTED', payload: { name: extension.name, }, }); } }), ); const failures = results.filter( (result): result is PromiseRejectedResult => result.status === 'rejected', ); if (failures.length < extensionsToRestart.length) { try { await context.services.agentContext?.config.reloadSkills(); await context.services.agentContext?.config.getAgentRegistry()?.reload(); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to reload skills or agents: ${getErrorMessage(error)}`, }); } } if (failures.length > 0) { const errorMessages = failures .map((failure, index) => { const extensionName = extensionsToRestart[index].name; return `${extensionName}: ${getErrorMessage(failure.reason)}`; }) .join('\n '); context.ui.addItem({ type: MessageType.ERROR, text: `Failed to reload some extensions:\n ${errorMessages}`, }); } else { const infoItem: HistoryItemInfo = { type: MessageType.INFO, text: `${extensionsToRestart.length} extension${s} reloaded successfully`, icon: emptyIcon, color: theme.text.primary, }; context.ui.addItem(infoItem); } } async function exploreAction( context: CommandContext, ): Promise { const settings = context.services.settings.merged; const useRegistryUI = settings.experimental?.extensionRegistry; if (useRegistryUI) { const extensionManager = context.services.agentContext?.config.getExtensionLoader(); if (extensionManager instanceof ExtensionManager) { return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { onSelect: async (extension, requestConsentOverride) => { debugLogger.log(`Selected extension: ${extension.extensionName}`); await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), extensionManager, }), }; } } const extensionsUrl = 'https://geminicli.com/extensions/'; // Only check for NODE_ENV for explicit test mode, not for unit test framework if (process.env['NODE_ENV'] === 'test') { context.ui.addItem({ type: MessageType.INFO, text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, }); } else if ( process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec' ) { context.ui.addItem({ type: MessageType.INFO, text: `View available extensions at ${extensionsUrl}`, }); } else { context.ui.addItem({ type: MessageType.INFO, text: `Opening extensions page in your browser: ${extensionsUrl}`, }); try { await open(extensionsUrl); } catch (_error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, }); } } } function getEnableDisableContext( context: CommandContext, argumentsString: string, ): { extensionManager: ExtensionManager; names: string[]; scope: SettingScope; } | null { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return null; } const parts = argumentsString.split(' '); const name = parts[0]; if ( name === '' || !( (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= (parts.length === 3 && parts[1] === '--scope') // --scope ) ) { context.ui.addItem({ type: MessageType.ERROR, text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, }); return null; } let scope: SettingScope; // Transform `--scope=` to `--scope `. if (parts.length === 2) { parts.push(...parts[1].split('=')); parts.splice(1, 1); } switch (parts[2].toLowerCase()) { case 'workspace': scope = SettingScope.Workspace; break; case 'user': scope = SettingScope.User; break; case 'session': scope = SettingScope.Session; break; default: context.ui.addItem({ type: MessageType.ERROR, text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, }); debugLogger.error(); return null; } let names: string[] = []; if (name === '--all') { let extensions = extensionLoader.getExtensions(); if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); } if (context.invocation?.name === 'disable') { extensions = extensions.filter((ext) => ext.isActive); } names = extensions.map((ext) => ext.name); } else { names = [name]; } return { extensionManager: extensionLoader, names, scope, }; } async function disableAction(context: CommandContext, args: string) { const enableContext = getEnableDisableContext(context, args); if (!enableContext) return; const { names, scope, extensionManager } = enableContext; for (const name of names) { await extensionManager.disableExtension(name, scope); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${name}" disabled for the scope "${scope}"`, }); } } async function enableAction(context: CommandContext, args: string) { const enableContext = getEnableDisableContext(context, args); if (!enableContext) return; const { names, scope, extensionManager } = enableContext; for (const name of names) { await extensionManager.enableExtension(name, scope); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${name}" enabled for the scope "${scope}"`, }); // Auto-enable any disabled MCP servers for this extension const extension = extensionManager .getExtensions() .find((e) => e.name === name); if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); const mcpClientManager = context.services.agentContext?.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); if (mcpClientManager && enabledServers.length > 0) { const restartPromises = enabledServers.map((serverName) => mcpClientManager.restartServer(serverName).catch((error) => { context.ui.addItem({ type: MessageType.WARNING, text: `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, }); }), ); await Promise.all(restartPromises); } if (enabledServers.length > 0) { context.ui.addItem({ type: MessageType.INFO, text: `Re-enabled MCP servers: ${enabledServers.join(', ')}`, }); } } } } async function installAction( context: CommandContext, args: string, requestConsentOverride?: (consent: string) => Promise, ) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; } const source = args.trim(); if (!source) { context.ui.addItem({ type: MessageType.ERROR, text: `Usage: /extensions install `, }); return; } // Validate that the source is either a valid URL or a valid file path. let isValid = false; try { // Check if it's a valid URL. new URL(source); isValid = true; } catch { // If not a URL, check for characters that are disallowed in file paths // and could be used for command injection. if (!/[;&|`'"]/.test(source)) { isValid = true; } } if (!isValid) { context.ui.addItem({ type: MessageType.ERROR, text: `Invalid source: ${source}`, }); return; } context.ui.addItem({ type: MessageType.INFO, text: `Installing extension from "${source}"...`, }); try { const installMetadata = await inferInstallMetadata(source); const extension = await extensionLoader.installOrUpdateExtension( installMetadata, undefined, requestConsentOverride, ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" installed successfully.`, }); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to install extension from "${source}": ${getErrorMessage( error, )}`, }); } } async function linkAction(context: CommandContext, args: string) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; } const sourceFilepath = args.trim(); if (!sourceFilepath) { context.ui.addItem({ type: MessageType.ERROR, text: `Usage: /extensions link `, }); return; } if (/[;&|`'"]/.test(sourceFilepath)) { context.ui.addItem({ type: MessageType.ERROR, text: `Source file path contains disallowed characters: ${sourceFilepath}`, }); return; } try { await stat(sourceFilepath); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Invalid source: ${sourceFilepath}`, }); debugLogger.error( `Failed to stat path "${sourceFilepath}": ${getErrorMessage(error)}`, ); return; } context.ui.addItem({ type: MessageType.INFO, text: `Linking extension from "${sourceFilepath}"...`, }); try { const installMetadata: ExtensionInstallMetadata = { source: sourceFilepath, type: 'link', }; const extension = await extensionLoader.installOrUpdateExtension(installMetadata); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, }); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to link extension from "${sourceFilepath}": ${getErrorMessage( error, )}`, }); } } async function uninstallAction(context: CommandContext, args: string) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; } const uninstallArgs = args.split(' ').filter((value) => value.length > 0); const all = uninstallArgs.includes('--all'); const names = uninstallArgs.filter((a) => !a.startsWith('--')); if (!all && names.length === 0) { context.ui.addItem({ type: MessageType.ERROR, text: `Usage: /extensions uninstall |--all`, }); return; } let namesToUninstall: string[] = []; if (all) { namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); } else { namesToUninstall = names; } if (namesToUninstall.length === 0) { context.ui.addItem({ type: MessageType.INFO, text: all ? 'No extensions installed.' : 'No extension name provided.', }); return; } for (const extensionName of namesToUninstall) { context.ui.addItem({ type: MessageType.INFO, text: `Uninstalling extension "${extensionName}"...`, }); try { await extensionLoader.uninstallExtension(extensionName, false); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extensionName}" uninstalled successfully.`, }); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to uninstall extension "${extensionName}": ${getErrorMessage( error, )}`, }); } } } async function configAction(context: CommandContext, args: string) { const parts = args.trim().split(/\s+/).filter(Boolean); let scope = ExtensionSettingScope.USER; const scopeEqIndex = parts.findIndex((p) => p.startsWith('--scope=')); if (scopeEqIndex > -1) { const scopeVal = parts[scopeEqIndex].split('=')[1]; if (scopeVal === 'workspace') { scope = ExtensionSettingScope.WORKSPACE; } else if (scopeVal === 'user') { scope = ExtensionSettingScope.USER; } parts.splice(scopeEqIndex, 1); } else { const scopeIndex = parts.indexOf('--scope'); if (scopeIndex > -1) { const scopeVal = parts[scopeIndex + 1]; if (scopeVal === 'workspace' || scopeVal === 'user') { scope = scopeVal === 'workspace' ? ExtensionSettingScope.WORKSPACE : ExtensionSettingScope.USER; parts.splice(scopeIndex, 2); } } } const otherArgs = parts; const name = otherArgs[0]; const setting = otherArgs[1]; if (name) { if (name.includes('/') || name.includes('\\') || name.includes('..')) { context.ui.addItem({ type: MessageType.ERROR, text: 'Invalid extension name. Names cannot contain path separators or "..".', }); return; } } const extensionManager = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, ); return; } const logger: ConfigLogger = { log: (message: string) => { context.ui.addItem({ type: MessageType.INFO, text: message.trim() }); }, error: (message: string) => context.ui.addItem({ type: MessageType.ERROR, text: message }), }; return { type: 'custom_dialog' as const, component: React.createElement(ConfigExtensionDialog, { extensionManager, onClose: () => context.ui.removeComponent(), extensionName: name, settingKey: setting, scope, configureAll: !name && !setting, loggerAdapter: logger, }), }; } /** * Exported for testing. */ export function completeExtensions( context: CommandContext, partialArg: string, ) { let extensions = context.services.agentContext?.config.getExtensions() ?? []; if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); } if ( context.invocation?.name === 'disable' || context.invocation?.name === 'restart' || context.invocation?.name === 'reload' ) { extensions = extensions.filter((ext) => ext.isActive); } const extensionNames = extensions.map((ext) => ext.name); const suggestions = extensionNames.filter((name) => name.startsWith(partialArg), ); if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { suggestions.unshift('--all'); } return suggestions; } export function completeExtensionsAndScopes( context: CommandContext, partialArg: string, ) { return completeExtensions(context, partialArg).flatMap((s) => [ `${s} --scope user`, `${s} --scope workspace`, `${s} --scope session`, ]); } const listExtensionsCommand: SlashCommand = { name: 'list', description: 'List active extensions', kind: CommandKind.BUILT_IN, autoExecute: true, action: listAction, }; const updateExtensionsCommand: SlashCommand = { name: 'update', description: 'Update extensions. Usage: update |--all', kind: CommandKind.BUILT_IN, autoExecute: false, action: updateAction, completion: completeExtensions, }; const disableCommand: SlashCommand = { name: 'disable', description: 'Disable an extension', kind: CommandKind.BUILT_IN, autoExecute: false, action: disableAction, completion: completeExtensionsAndScopes, }; const enableCommand: SlashCommand = { name: 'enable', description: 'Enable an extension', kind: CommandKind.BUILT_IN, autoExecute: false, action: enableAction, completion: completeExtensionsAndScopes, }; const installCommand: SlashCommand = { name: 'install', description: 'Install an extension from a git repo or local path', kind: CommandKind.BUILT_IN, autoExecute: false, action: installAction, }; const linkCommand: SlashCommand = { name: 'link', description: 'Link an extension from a local path', kind: CommandKind.BUILT_IN, autoExecute: false, action: linkAction, }; const uninstallCommand: SlashCommand = { name: 'uninstall', description: 'Uninstall an extension', kind: CommandKind.BUILT_IN, autoExecute: false, action: uninstallAction, completion: completeExtensions, }; const exploreExtensionsCommand: SlashCommand = { name: 'explore', description: 'Open extensions page in your browser', kind: CommandKind.BUILT_IN, autoExecute: true, action: exploreAction, }; const reloadCommand: SlashCommand = { name: 'reload', altNames: ['restart'], description: 'Reload all extensions', kind: CommandKind.BUILT_IN, autoExecute: false, action: restartAction, completion: completeExtensions, }; const configCommand: SlashCommand = { name: 'config', description: 'Configure extension settings', kind: CommandKind.BUILT_IN, autoExecute: false, action: configAction, }; export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { const conditionalCommands = enableExtensionReloading ? [ disableCommand, enableCommand, installCommand, uninstallCommand, linkCommand, configCommand, ] : []; return { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, reloadCommand, ...conditionalCommands, ], action: (context, args) => // Default to list if no subcommand is provided listExtensionsCommand.action!(context, args), }; } ================================================ FILE: packages/cli/src/ui/commands/footerCommand.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type SlashCommand, type CommandContext, type OpenCustomDialogActionReturn, CommandKind, } from './types.js'; import { FooterConfigDialog } from '../components/FooterConfigDialog.js'; export const footerCommand: SlashCommand = { name: 'footer', altNames: ['statusline'], description: 'Configure which items appear in the footer (statusline)', kind: CommandKind.BUILT_IN, autoExecute: true, action: (context: CommandContext): OpenCustomDialogActionReturn => ({ type: 'custom_dialog', component: , }), }; ================================================ FILE: packages/cli/src/ui/commands/helpCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { helpCommand } from './helpCommand.js'; import { CommandKind, type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; describe('helpCommand', () => { let mockContext: CommandContext; const originalEnv = { ...process.env }; beforeEach(() => { mockContext = createMockCommandContext({ ui: { addItem: vi.fn(), }, } as unknown as CommandContext); }); afterEach(() => { process.env = { ...originalEnv }; vi.clearAllMocks(); }); it('should add a help message to the UI history', async () => { if (!helpCommand.action) { throw new Error('Help command has no action'); } await helpCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.HELP, timestamp: expect.any(Date), }), ); }); it('should have the correct command properties', () => { expect(helpCommand.name).toBe('help'); expect(helpCommand.kind).toBe(CommandKind.BUILT_IN); expect(helpCommand.description).toBe('For help on gemini-cli'); }); }); ================================================ FILE: packages/cli/src/ui/commands/helpCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; import { MessageType, type HistoryItemHelp } from '../types.js'; export const helpCommand: SlashCommand = { name: 'help', kind: CommandKind.BUILT_IN, description: 'For help on gemini-cli', autoExecute: true, action: async (context) => { const helpItem: Omit = { type: MessageType.HELP, timestamp: new Date(), }; context.ui.addItem(helpItem); }, }; ================================================ FILE: packages/cli/src/ui/commands/hooksCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hooksCommand } from './hooksCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { HookType, HookEventName, ConfigSource, type HookRegistryEntry, } from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; import { SettingScope } from '../../config/settings.js'; describe('hooksCommand', () => { let mockContext: CommandContext; let mockHookSystem: { getAllHooks: ReturnType; setHookEnabled: ReturnType; getRegistry: ReturnType; }; let mockConfig: { getHookSystem: ReturnType; getEnableHooks: ReturnType; updateDisabledHooks: ReturnType; }; let mockSettings: { merged: { hooksConfig?: { disabled?: string[]; }; }; setValue: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any workspace: { path: string; settings: any }; // eslint-disable-next-line @typescript-eslint/no-explicit-any user: { path: string; settings: any }; forScope: ReturnType; }; beforeEach(() => { vi.clearAllMocks(); // Create mock hook system mockHookSystem = { getAllHooks: vi.fn().mockReturnValue([]), setHookEnabled: vi.fn(), getRegistry: vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), }), }; // Create mock config mockConfig = { getHookSystem: vi.fn().mockReturnValue(mockHookSystem), getEnableHooks: vi.fn().mockReturnValue(true), updateDisabledHooks: vi.fn(), }; // Create mock settings const mockUser = { path: '/mock/user.json', settings: { hooksConfig: { disabled: [] } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; const mockWorkspace = { path: '/mock/workspace.json', settings: { hooksConfig: { disabled: [] } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; mockSettings = { merged: { hooksConfig: { disabled: [], }, }, setValue: vi.fn(), workspace: mockWorkspace, user: mockUser, forScope: vi.fn((scope) => { if (scope === SettingScope.User) return mockUser; if (scope === SettingScope.Workspace) return mockWorkspace; return mockUser; }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; // Create mock context with config and settings mockContext = createMockCommandContext({ services: { agentContext: { config: mockConfig }, settings: mockSettings, }, }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('root command', () => { it('should have the correct name and description', () => { expect(hooksCommand.name).toBe('hooks'); expect(hooksCommand.description).toBe('Manage hooks'); }); it('should have all expected subcommands', () => { expect(hooksCommand.subCommands).toBeDefined(); expect(hooksCommand.subCommands).toHaveLength(5); const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name); expect(subCommandNames).toContain('panel'); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); expect(subCommandNames).toContain('enable-all'); expect(subCommandNames).toContain('disable-all'); }); it('should delegate to panel action when invoked without subcommand', async () => { if (!hooksCommand.action) { throw new Error('hooks command must have an action'); } mockHookSystem.getAllHooks.mockReturnValue([ createMockHook('test-hook', HookEventName.BeforeTool, true), ]); const result = await hooksCommand.action(mockContext, ''); expect(result).toHaveProperty('type', 'custom_dialog'); expect(result).toHaveProperty('component'); }); }); describe('panel subcommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', ); if (!panelCmd?.action) { throw new Error('panel command must have an action'); } const result = await panelCmd.action(contextWithoutConfig, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should return custom_dialog even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', ); if (!panelCmd?.action) { throw new Error('panel command must have an action'); } const result = await panelCmd.action(mockContext, ''); expect(result).toHaveProperty('type', 'custom_dialog'); expect(result).toHaveProperty('component'); }); it('should return custom_dialog when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); (mockContext.services.settings.merged as Record)[ 'hooksConfig' ] = { enabled: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', ); if (!panelCmd?.action) { throw new Error('panel command must have an action'); } const result = await panelCmd.action(mockContext, ''); expect(result).toHaveProperty('type', 'custom_dialog'); expect(result).toHaveProperty('component'); }); it('should return custom_dialog when hooks are configured', async () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('echo-test', HookEventName.BeforeTool, true), createMockHook('notify', HookEventName.AfterAgent, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); (mockContext.services.settings.merged as Record)[ 'hooksConfig' ] = { enabled: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', ); if (!panelCmd?.action) { throw new Error('panel command must have an action'); } const result = await panelCmd.action(mockContext, ''); expect(result).toHaveProperty('type', 'custom_dialog'); expect(result).toHaveProperty('component'); }); }); describe('enable subcommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.action) { throw new Error('enable command must have an action'); } const result = await enableCmd.action(contextWithoutConfig, 'test-hook'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should return error when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.action) { throw new Error('enable command must have an action'); } const result = await enableCmd.action(mockContext, 'test-hook'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }); }); it('should return error when hook name is not provided', async () => { const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.action) { throw new Error('enable command must have an action'); } const result = await enableCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /hooks enable ', }); }); it('should enable a hook and update settings', async () => { // Update the user settings with disabled hooks mockSettings.user.settings.hooksConfig.disabled = [ 'test-hook', 'other-hook', ]; mockSettings.workspace.settings.hooksConfig.disabled = []; const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.action) { throw new Error('enable command must have an action'); } const result = await enableCmd.action(mockContext, 'test-hook'); expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( SettingScope.User, 'hooksConfig.disabled', ['other-hook'], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'test-hook', true, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Hook "test-hook" enabled by removing it from the disabled list in user (/mock/user.json) and workspace (/mock/workspace.json) settings.', }); }); it('should complete hook names using friendly names', () => { const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', )!; const hookEntry = createMockHook( './hooks/test.sh', HookEventName.BeforeTool, false, // Must be disabled for enable completion ); hookEntry.config.name = 'friendly-name'; mockHookSystem.getAllHooks.mockReturnValue([hookEntry]); const completions = enableCmd.completion!(mockContext, 'frie'); expect(completions).toContain('friendly-name'); }); }); describe('disable subcommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', ); if (!disableCmd?.action) { throw new Error('disable command must have an action'); } const result = await disableCmd.action(contextWithoutConfig, 'test-hook'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should return error when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', ); if (!disableCmd?.action) { throw new Error('disable command must have an action'); } const result = await disableCmd.action(mockContext, 'test-hook'); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }); }); it('should return error when hook name is not provided', async () => { const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', ); if (!disableCmd?.action) { throw new Error('disable command must have an action'); } const result = await disableCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /hooks disable ', }); }); it('should disable a hook and update settings', async () => { // Ensure not disabled anywhere mockSettings.workspace.settings.hooksConfig.disabled = []; mockSettings.user.settings.hooksConfig.disabled = []; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', ); if (!disableCmd?.action) { throw new Error('disable command must have an action'); } const result = await disableCmd.action(mockContext, 'test-hook'); // Should default to workspace if present expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'hooksConfig.disabled', ['test-hook'], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'test-hook', false, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Hook "test-hook" disabled by adding it to the disabled list in workspace (/mock/workspace.json) settings.', }); }); it('should return info when hook is already disabled', async () => { // Update the context's settings with the hook already disabled in Workspace mockSettings.workspace.settings.hooksConfig.disabled = ['test-hook']; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', ); if (!disableCmd?.action) { throw new Error('disable command must have an action'); } const result = await disableCmd.action(mockContext, 'test-hook'); expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Hook "test-hook" is already disabled.', }); }); it('should complete hook names using friendly names', () => { const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', )!; const hookEntry = createMockHook( './hooks/test.sh', HookEventName.BeforeTool, true, // Must be enabled for disable completion ); hookEntry.config.name = 'friendly-name'; mockHookSystem.getAllHooks.mockReturnValue([hookEntry]); const completions = disableCmd.completion!(mockContext, 'frie'); expect(completions).toContain('friendly-name'); }); }); describe('completion', () => { it('should return empty array when config is not available', () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.completion) { throw new Error('enable command must have completion'); } const result = enableCmd.completion(contextWithoutConfig, 'test'); expect(result).toEqual([]); }); it('should return empty array when hook system is not enabled', () => { mockConfig.getHookSystem.mockReturnValue(null); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.completion) { throw new Error('enable command must have completion'); } const result = enableCmd.completion(mockContext, 'test'); expect(result).toEqual([]); }); it('should return matching hook names based on status', () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('test-hook-enabled', HookEventName.BeforeTool, true), createMockHook('test-hook-disabled', HookEventName.AfterTool, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', )!; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', )!; const enableResult = enableCmd.completion!(mockContext, 'test'); expect(enableResult).toEqual(['test-hook-disabled']); const disableResult = disableCmd.completion!(mockContext, 'test'); expect(disableResult).toEqual(['test-hook-enabled']); }); it('should return all relevant hook names when partial is empty', () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('hook-enabled', HookEventName.BeforeTool, true), createMockHook('hook-disabled', HookEventName.AfterTool, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', )!; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', )!; expect(enableCmd.completion!(mockContext, '')).toEqual(['hook-disabled']); expect(disableCmd.completion!(mockContext, '')).toEqual(['hook-enabled']); }); it('should handle hooks without command name gracefully', () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('test-hook', HookEventName.BeforeTool, false), { ...createMockHook('', HookEventName.AfterTool, false), config: { command: '', type: HookType.Command, timeout: 30 }, }, ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', ); if (!enableCmd?.completion) { throw new Error('enable command must have completion'); } const result = enableCmd.completion(mockContext, 'test'); expect(result).toEqual(['test-hook']); }); }); describe('enable-all subcommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const enableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable-all', ); if (!enableAllCmd?.action) { throw new Error('enable-all command must have an action'); } const result = await enableAllCmd.action(contextWithoutConfig, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should return error when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const enableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable-all', ); if (!enableAllCmd?.action) { throw new Error('enable-all command must have an action'); } const result = await enableAllCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }); }); it('should enable all disabled hooks', async () => { const mockHooks = [ createMockHook('hook-1', HookEventName.BeforeTool, false), createMockHook('hook-2', HookEventName.AfterTool, false), createMockHook('hook-3', HookEventName.BeforeAgent, true), // already enabled ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable-all', ); if (!enableAllCmd?.action) { throw new Error('enable-all command must have an action'); } const result = await enableAllCmd.action(mockContext, ''); expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( expect.any(String), // enableAll uses legacy logic so it might return 'Workspace' or 'User' depending on ternary 'hooksConfig.disabled', [], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'hook-1', true, ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'hook-2', true, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Enabled 2 hook(s) successfully.', }); }); it('should return info when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); const enableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable-all', ); if (!enableAllCmd?.action) { throw new Error('enable-all command must have an action'); } const result = await enableAllCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No hooks configured.', }); }); it('should return info when all hooks are already enabled', async () => { const mockHooks = [ createMockHook('hook-1', HookEventName.BeforeTool, true), createMockHook('hook-2', HookEventName.AfterTool, true), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable-all', ); if (!enableAllCmd?.action) { throw new Error('enable-all command must have an action'); } const result = await enableAllCmd.action(mockContext, ''); expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'All hooks are already enabled.', }); }); }); describe('disable-all subcommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const disableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable-all', ); if (!disableAllCmd?.action) { throw new Error('disable-all command must have an action'); } const result = await disableAllCmd.action(contextWithoutConfig, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should return error when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const disableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable-all', ); if (!disableAllCmd?.action) { throw new Error('disable-all command must have an action'); } const result = await disableAllCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }); }); it('should disable all enabled hooks', async () => { const mockHooks = [ createMockHook('hook-1', HookEventName.BeforeTool, true), createMockHook('hook-2', HookEventName.AfterTool, true), createMockHook('hook-3', HookEventName.BeforeAgent, false), // already disabled ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const disableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable-all', ); if (!disableAllCmd?.action) { throw new Error('disable-all command must have an action'); } const result = await disableAllCmd.action(mockContext, ''); expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( expect.any(String), 'hooksConfig.disabled', ['hook-1', 'hook-2', 'hook-3'], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'hook-1', false, ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( 'hook-2', false, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Disabled 2 hook(s) successfully.', }); }); it('should return info when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); const disableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable-all', ); if (!disableAllCmd?.action) { throw new Error('disable-all command must have an action'); } const result = await disableAllCmd.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No hooks configured.', }); }); it('should return info when all hooks are already disabled', async () => { const mockHooks = [ createMockHook('hook-1', HookEventName.BeforeTool, false), createMockHook('hook-2', HookEventName.AfterTool, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const disableAllCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable-all', ); if (!disableAllCmd?.action) { throw new Error('disable-all command must have an action'); } const result = await disableAllCmd.action(mockContext, ''); expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'All hooks are already disabled.', }); }); }); }); /** * Helper function to create a mock HookRegistryEntry */ function createMockHook( command: string, eventName: HookEventName, enabled: boolean, ): HookRegistryEntry { return { config: { command, type: HookType.Command, timeout: 30, }, source: ConfigSource.Project, eventName, matcher: undefined, sequential: false, enabled, }; } ================================================ FILE: packages/cli/src/ui/commands/hooksCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { createElement } from 'react'; import type { SlashCommand, CommandContext, OpenCustomDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import type { HookRegistryEntry, MessageActionReturn, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; import { enableHook, disableHook } from '../../utils/hookSettings.js'; import { renderHookActionFeedback } from '../../utils/hookUtils.js'; import { HooksDialog } from '../components/HooksDialog.js'; /** * Display a formatted list of hooks with their status in a dialog */ function panelAction( context: CommandContext, ): MessageActionReturn | OpenCustomDialogActionReturn { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const hookSystem = config.getHookSystem(); const allHooks = hookSystem?.getAllHooks() || []; return { type: 'custom_dialog', component: createElement(HooksDialog, { hooks: allHooks, onClose: () => context.ui.removeComponent(), }), }; } /** * Enable a hook by name */ async function enableAction( context: CommandContext, args: string, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const hookSystem = config.getHookSystem(); if (!hookSystem) { return { type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }; } const hookName = args.trim(); if (!hookName) { return { type: 'message', messageType: 'error', content: 'Usage: /hooks enable ', }; } const settings = context.services.settings; const result = enableHook(settings, hookName); if (result.status === 'success') { hookSystem.setHookEnabled(hookName, true); } const feedback = renderHookActionFeedback( result, (label, path) => `${label} (${path})`, ); return { type: 'message', messageType: result.status === 'error' ? 'error' : 'info', content: feedback, }; } /** * Disable a hook by name */ async function disableAction( context: CommandContext, args: string, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const hookSystem = config.getHookSystem(); if (!hookSystem) { return { type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }; } const hookName = args.trim(); if (!hookName) { return { type: 'message', messageType: 'error', content: 'Usage: /hooks disable ', }; } const settings = context.services.settings; const scope = settings.workspace ? SettingScope.Workspace : SettingScope.User; const result = disableHook(settings, hookName, scope); if (result.status === 'success') { hookSystem.setHookEnabled(hookName, false); } const feedback = renderHookActionFeedback( result, (label, path) => `${label} (${path})`, ); return { type: 'message', messageType: result.status === 'error' ? 'error' : 'info', content: feedback, }; } /** * Completion function for enabled hook names (to be disabled) */ function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); if (!hookSystem) return []; const allHooks = hookSystem.getAllHooks(); return allHooks .filter((hook) => hook.enabled) .map((hook) => getHookDisplayName(hook)) .filter((name) => name.startsWith(partialArg)); } /** * Completion function for disabled hook names (to be enabled) */ function completeDisabledHookNames( context: CommandContext, partialArg: string, ): string[] { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); if (!hookSystem) return []; const allHooks = hookSystem.getAllHooks(); return allHooks .filter((hook) => !hook.enabled) .map((hook) => getHookDisplayName(hook)) .filter((name) => name.startsWith(partialArg)); } /** * Get a display name for a hook */ function getHookDisplayName(hook: HookRegistryEntry): string { return hook.config.name || hook.config.command || 'unknown-hook'; } /** * Enable all hooks by clearing the disabled list */ async function enableAllAction( context: CommandContext, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const hookSystem = config.getHookSystem(); if (!hookSystem) { return { type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }; } const settings = context.services.settings; const allHooks = hookSystem.getAllHooks(); if (allHooks.length === 0) { return { type: 'message', messageType: 'info', content: 'No hooks configured.', }; } const disabledHooks = allHooks.filter((hook) => !hook.enabled); if (disabledHooks.length === 0) { return { type: 'message', messageType: 'info', content: 'All hooks are already enabled.', }; } try { const scopes = [SettingScope.Workspace, SettingScope.User]; for (const scope of scopes) { if (isLoadableSettingScope(scope)) { settings.setValue(scope, 'hooksConfig.disabled', []); } } for (const hook of disabledHooks) { const hookName = getHookDisplayName(hook); hookSystem.setHookEnabled(hookName, true); } return { type: 'message', messageType: 'info', content: `Enabled ${disabledHooks.length} hook(s) successfully.`, }; } catch (error) { return { type: 'message', messageType: 'error', content: `Failed to enable hooks: ${getErrorMessage(error)}`, }; } } /** * Disable all hooks by adding all hooks to the disabled list */ async function disableAllAction( context: CommandContext, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const hookSystem = config.getHookSystem(); if (!hookSystem) { return { type: 'message', messageType: 'error', content: 'Hook system is not enabled.', }; } const settings = context.services.settings; const allHooks = hookSystem.getAllHooks(); if (allHooks.length === 0) { return { type: 'message', messageType: 'info', content: 'No hooks configured.', }; } const enabledHooks = allHooks.filter((hook) => hook.enabled); if (enabledHooks.length === 0) { return { type: 'message', messageType: 'info', content: 'All hooks are already disabled.', }; } try { const allHookNames = allHooks.map((hook) => getHookDisplayName(hook)); const scope = settings.workspace ? SettingScope.Workspace : SettingScope.User; settings.setValue(scope, 'hooksConfig.disabled', allHookNames); for (const hook of enabledHooks) { const hookName = getHookDisplayName(hook); hookSystem.setHookEnabled(hookName, false); } return { type: 'message', messageType: 'info', content: `Disabled ${enabledHooks.length} hook(s) successfully.`, }; } catch (error) { return { type: 'message', messageType: 'error', content: `Failed to disable hooks: ${getErrorMessage(error)}`, }; } } const panelCommand: SlashCommand = { name: 'panel', altNames: ['list', 'show'], description: 'Display all registered hooks with their status', kind: CommandKind.BUILT_IN, autoExecute: true, action: panelAction, }; const enableCommand: SlashCommand = { name: 'enable', description: 'Enable a hook by name', kind: CommandKind.BUILT_IN, autoExecute: true, action: enableAction, completion: completeDisabledHookNames, }; const disableCommand: SlashCommand = { name: 'disable', description: 'Disable a hook by name', kind: CommandKind.BUILT_IN, autoExecute: true, action: disableAction, completion: completeEnabledHookNames, }; const enableAllCommand: SlashCommand = { name: 'enable-all', altNames: ['enableall'], description: 'Enable all disabled hooks', kind: CommandKind.BUILT_IN, autoExecute: true, action: enableAllAction, }; const disableAllCommand: SlashCommand = { name: 'disable-all', altNames: ['disableall'], description: 'Disable all enabled hooks', kind: CommandKind.BUILT_IN, autoExecute: true, action: disableAllAction, }; export const hooksCommand: SlashCommand = { name: 'hooks', description: 'Manage hooks', kind: CommandKind.BUILT_IN, subCommands: [ panelCommand, enableCommand, disableCommand, enableAllCommand, disableAllCommand, ], action: (context: CommandContext) => panelCommand.action!(context, ''), }; ================================================ FILE: packages/cli/src/ui/commands/ideCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type MockInstance, } from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; import { IDE_DEFINITIONS } from '@google/gemini-cli-core'; import * as core from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, getOauthClient: vi.fn(original.getOauthClient), getIdeInstaller: vi.fn(original.getIdeInstaller), IdeClient: { getInstance: vi.fn(), }, }; }); describe('ideCommand', () => { let mockContext: CommandContext; let mockIdeClient: core.IdeClient; let platformSpy: MockInstance; beforeEach(() => { vi.resetAllMocks(); mockIdeClient = { reconnect: vi.fn(), disconnect: vi.fn(), connect: vi.fn(), getCurrentIde: vi.fn(), getConnectionStatus: vi.fn(), getDetectedIdeDisplayName: vi.fn(), } as unknown as core.IdeClient; vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient); vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue( 'VS Code', ); mockContext = { ui: { addItem: vi.fn(), }, services: { settings: { setValue: vi.fn(), }, agentContext: { config: { getIdeMode: vi.fn(), setIdeMode: vi.fn(), getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), }, }, }, } as unknown as CommandContext; platformSpy = vi.spyOn(process, 'platform', 'get'); }); afterEach(() => { vi.restoreAllMocks(); }); it('should return the ide command', async () => { vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( IDE_DEFINITIONS.vscode, ); vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, }); const command = await ideCommand(); expect(command).not.toBeNull(); expect(command.name).toBe('ide'); expect(command.subCommands).toHaveLength(3); expect(command.subCommands?.[0].name).toBe('enable'); expect(command.subCommands?.[1].name).toBe('status'); expect(command.subCommands?.[2].name).toBe('install'); }); it('should show disable command when connected', async () => { vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( IDE_DEFINITIONS.vscode, ); vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Connected, }); const command = await ideCommand(); expect(command).not.toBeNull(); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).toContain('disable'); expect(subCommandNames).not.toContain('enable'); }); describe('status subcommand', () => { beforeEach(() => { vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( IDE_DEFINITIONS.vscode, ); }); it('should show connected status', async () => { vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Connected, }); const command = await ideCommand(); const result = await command.subCommands!.find( (c) => c.name === 'status', )!.action!(mockContext, ''); expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: '🟢 Connected to VS Code', }); }); it('should show connecting status', async () => { vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Connecting, }); const command = await ideCommand(); const result = await command.subCommands!.find( (c) => c.name === 'status', )!.action!(mockContext, ''); expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: `🟡 Connecting...`, }); }); it('should show disconnected status', async () => { vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, }); const command = await ideCommand(); const result = await command.subCommands!.find( (c) => c.name === 'status', )!.action!(mockContext, ''); expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', content: `🔴 Disconnected`, }); }); it('should show disconnected status with details', async () => { const details = 'Something went wrong'; vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, details, }); const command = await ideCommand(); const result = await command.subCommands!.find( (c) => c.name === 'status', )!.action!(mockContext, ''); expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', content: `🔴 Disconnected: ${details}`, }); }); }); describe('install subcommand', () => { const mockInstall = vi.fn(); beforeEach(() => { vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue( IDE_DEFINITIONS.vscode, ); vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Disconnected, }); vi.mocked(core.getIdeInstaller).mockReturnValue({ install: mockInstall, }); platformSpy.mockReturnValue('linux'); }); it('should install the extension', async () => { vi.useFakeTimers(); mockInstall.mockResolvedValue({ success: true, message: 'Successfully installed.', }); const command = await ideCommand(); // For the polling loop inside the action. vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({ status: core.IDEConnectionStatus.Connected, }); const actionPromise = command.subCommands!.find( (c) => c.name === 'install', )!.action!(mockContext, ''); await vi.runAllTimersAsync(); await actionPromise; expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode); expect(mockInstall).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', text: `Installing IDE companion...`, }), expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', text: 'Successfully installed.', }), expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', text: '🟢 Connected to VS Code', }), expect.any(Number), ); vi.useRealTimers(); }, 10000); it('should show an error if installation fails', async () => { mockInstall.mockResolvedValue({ success: false, message: 'Installation failed.', }); const command = await ideCommand(); await command.subCommands!.find((c) => c.name === 'install')!.action!( mockContext, '', ); expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode); expect(mockInstall).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', text: `Installing IDE companion...`, }), expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', text: 'Installation failed.', }), expect.any(Number), ); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/ideCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type Config, IdeClient, type File, logIdeConnection, IdeConnectionEvent, IdeConnectionType, } from '@google/gemini-cli-core'; import { getIdeInstaller, IDEConnectionStatus, ideContextStore, GEMINI_CLI_COMPANION_EXTENSION_NAME, } from '@google/gemini-cli-core'; import path from 'node:path'; import type { CommandContext, SlashCommand, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { SettingScope } from '../../config/settings.js'; function getIdeStatusMessage(ideClient: IdeClient): { messageType: 'info' | 'error'; content: string; } { const connection = ideClient.getConnectionStatus(); switch (connection.status) { case IDEConnectionStatus.Connected: return { messageType: 'info', content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, }; case IDEConnectionStatus.Connecting: return { messageType: 'info', content: `🟡 Connecting...`, }; default: { let content = `🔴 Disconnected`; if (connection?.details) { content += `: ${connection.details}`; } return { messageType: 'error', content, }; } } } function formatFileList(openFiles: File[]): string { const basenameCounts = new Map(); for (const file of openFiles) { const basename = path.basename(file.path); basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1); } const fileList = openFiles .map((file: File) => { const basename = path.basename(file.path); const isDuplicate = (basenameCounts.get(basename) || 0) > 1; const parentDir = path.basename(path.dirname(file.path)); const displayName = isDuplicate ? `${basename} (/${parentDir})` : basename; return ` - ${displayName}${file.isActive ? ' (active)' : ''}`; }) .join('\n'); const infoMessage = ` (Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`; return `\n\nOpen files:\n${fileList}\n${infoMessage}`; } async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{ messageType: 'info' | 'error'; content: string; }> { const connection = ideClient.getConnectionStatus(); switch (connection.status) { case IDEConnectionStatus.Connected: { let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`; const context = ideContextStore.get(); const openFiles = context?.workspaceState?.openFiles; if (openFiles && openFiles.length > 0) { content += formatFileList(openFiles); } return { messageType: 'info', content, }; } case IDEConnectionStatus.Connecting: return { messageType: 'info', content: `🟡 Connecting...`, }; default: { let content = `🔴 Disconnected`; if (connection?.details) { content += `: ${connection.details}`; } return { messageType: 'error', content, }; } } } async function setIdeModeAndSyncConnection( config: Config, value: boolean, options: { logToConsole?: boolean } = {}, ): Promise { config.setIdeMode(value); const ideClient = await IdeClient.getInstance(); if (value) { await ideClient.connect(options); logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION)); } else { await ideClient.disconnect(); } } export const ideCommand = async (): Promise => { const ideClient = await IdeClient.getInstance(); const currentIDE = ideClient.getCurrentIde(); if (!currentIDE) { return { name: 'ide', description: 'Manage IDE integration', kind: CommandKind.BUILT_IN, autoExecute: false, action: (): SlashCommandActionReturn => ({ type: 'message', messageType: 'error', content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`, }) as const, }; } const ideSlashCommand: SlashCommand = { name: 'ide', description: 'Manage IDE integration', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [], }; const statusCommand: SlashCommand = { name: 'status', description: 'Check status of IDE integration', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (): Promise => { const { messageType, content } = await getIdeStatusMessageWithFiles(ideClient); return { type: 'message', messageType, content, } as const; }, }; const installCommand: SlashCommand = { name: 'install', description: `Install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`, kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { const installer = getIdeInstaller(currentIDE); if (!installer) { context.ui.addItem( { type: 'error', text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, }, Date.now(), ); return; } context.ui.addItem( { type: 'info', text: `Installing IDE companion...`, }, Date.now(), ); const result = await installer.install(); context.ui.addItem( { type: result.success ? 'info' : 'error', text: result.message, }, Date.now(), ); if (result.success) { context.services.settings.setValue( SettingScope.User, 'ide.enabled', true, ); // Poll for up to 5 seconds for the extension to activate. for (let i = 0; i < 10; i++) { await setIdeModeAndSyncConnection( context.services.agentContext!.config, true, { logToConsole: false, }, ); if ( ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected ) { break; } await new Promise((resolve) => setTimeout(resolve, 500)); } const { messageType, content } = getIdeStatusMessage(ideClient); if (messageType === 'error') { context.ui.addItem( { type: messageType, text: `Failed to automatically enable IDE integration. To fix this, run the CLI in a new terminal window.`, }, Date.now(), ); } else { context.ui.addItem( { type: messageType, text: content, }, Date.now(), ); } } }, }; const enableCommand: SlashCommand = { name: 'enable', description: 'Enable IDE integration', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, 'ide.enabled', true, ); await setIdeModeAndSyncConnection( context.services.agentContext!.config, true, ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { type: messageType, text: content, }, Date.now(), ); }, }; const disableCommand: SlashCommand = { name: 'disable', description: 'Disable IDE integration', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, 'ide.enabled', false, ); await setIdeModeAndSyncConnection( context.services.agentContext!.config, false, ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { type: messageType, text: content, }, Date.now(), ); }, }; const { status } = ideClient.getConnectionStatus(); const isConnected = status === IDEConnectionStatus.Connected; if (isConnected) { ideSlashCommand.subCommands = [statusCommand, disableCommand]; } else { ideSlashCommand.subCommands = [ enableCommand, statusCommand, installCommand, ]; } return ideSlashCommand; }; ================================================ FILE: packages/cli/src/ui/commands/initCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { initCommand } from './initCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; import type { SubmitPromptActionReturn } from '@google/gemini-cli-core'; // Mock the 'fs' module vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, existsSync: vi.fn(), writeFileSync: vi.fn(), }; }); describe('initCommand', () => { let mockContext: CommandContext; const targetDir = '/test/dir'; const geminiMdPath = path.join(targetDir, 'GEMINI.md'); beforeEach(() => { // Create a fresh mock context for each test mockContext = createMockCommandContext({ services: { agentContext: { config: { getTargetDir: () => targetDir, }, }, }, }); }); afterEach(() => { // Clear all mocks after each test vi.clearAllMocks(); }); it('should inform the user if GEMINI.md already exists', async () => { // Arrange: Simulate that the file exists vi.mocked(fs.existsSync).mockReturnValue(true); // Act: Run the command's action const result = await initCommand.action!(mockContext, ''); // Assert: Check for the correct informational message expect(result).toEqual({ type: 'message', messageType: 'info', content: 'A GEMINI.md file already exists in this directory. No changes were made.', }); // Assert: Ensure no file was written expect(fs.writeFileSync).not.toHaveBeenCalled(); }); it('should create GEMINI.md and submit a prompt if it does not exist', async () => { // Arrange: Simulate that the file does not exist vi.mocked(fs.existsSync).mockReturnValue(false); // Act: Run the command's action const result = (await initCommand.action!( mockContext, '', )) as SubmitPromptActionReturn; // Assert: Check that writeFileSync was called correctly expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); // Assert: Check that an informational message was added to the UI expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: 'info', text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', }, expect.any(Number), ); // Assert: Check that the correct prompt is submitted expect(result.type).toBe('submit_prompt'); expect(result.content).toContain( 'You are an AI agent that brings the power of Gemini', ); }); it('should return an error if config is not available', async () => { // Arrange: Create a context without config const noConfigContext = createMockCommandContext(); if (noConfigContext.services) { noConfigContext.services.agentContext = null; } // Act: Run the command's action const result = await initCommand.action!(noConfigContext, ''); // Assert: Check for the correct error message expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Configuration not available.', }); }); }); ================================================ FILE: packages/cli/src/ui/commands/initCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { CommandContext, SlashCommand, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { performInit } from '@google/gemini-cli-core'; export const initCommand: SlashCommand = { name: 'init', description: 'Analyzes the project and creates a tailored GEMINI.md file', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( context: CommandContext, _args: string, ): Promise => { if (!context.services.agentContext?.config) { return { type: 'message', messageType: 'error', content: 'Configuration not available.', }; } const targetDir = context.services.agentContext.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); if (result.type === 'submit_prompt') { // Create an empty GEMINI.md file fs.writeFileSync(geminiMdPath, '', 'utf8'); context.ui.addItem( { type: 'info', text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', }, Date.now(), ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result as SlashCommandActionReturn; }, }; ================================================ FILE: packages/cli/src/ui/commands/mcpCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mcpCommand } from './mcpCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MCPServerStatus, MCPDiscoveryState, getMCPServerStatus, getMCPDiscoveryState, DiscoveredMCPTool, type MessageBus, } from '@google/gemini-cli-core'; import type { CallableTool } from '@google/genai'; import { MessageType } from '../types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); const mockAuthenticate = vi.fn(); return { ...actual, getMCPServerStatus: vi.fn(), getMCPDiscoveryState: vi.fn(), MCPOAuthProvider: vi.fn(() => ({ authenticate: mockAuthenticate, })), MCPOAuthTokenStorage: vi.fn(() => ({ getToken: vi.fn(), isTokenExpired: vi.fn(), })), }; }); const mockMessageBus = { publish: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), } as unknown as MessageBus; // Helper function to create a mock DiscoveredMCPTool const createMockMCPTool = ( name: string, serverName: string, description?: string, ) => new DiscoveredMCPTool( { callTool: vi.fn(), tool: vi.fn(), } as unknown as CallableTool, serverName, name, description || 'Mock tool description', { type: 'object', properties: {} }, mockMessageBus, undefined, // trust undefined, // isReadOnly undefined, // nameOverride undefined, // cliConfig undefined, // extensionName undefined, // extensionId ); describe('mcpCommand', () => { let mockContext: ReturnType; let mockConfig: { getToolRegistry: ReturnType; getMcpServers: ReturnType; getBlockedMcpServers: ReturnType; getPromptRegistry: ReturnType; getGeminiClient: ReturnType; getMcpClientManager: ReturnType; getResourceRegistry: ReturnType; setUserInteractedWithMcp: ReturnType; getLastMcpError: ReturnType; }; beforeEach(() => { vi.clearAllMocks(); // Set up default mock environment vi.unstubAllEnvs(); // Default mock implementations vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, ); // Create mock config with all necessary methods mockConfig = { getToolRegistry: vi.fn().mockReturnValue({ getAllTools: vi.fn().mockReturnValue([]), }), getMcpServers: vi.fn().mockReturnValue({}), getBlockedMcpServers: vi.fn().mockReturnValue([]), getPromptRegistry: vi.fn().mockReturnValue({ getAllPrompts: vi.fn().mockReturnValue([]), getPromptsByServer: vi.fn().mockReturnValue([]), }), getGeminiClient: vi.fn(), getMcpClientManager: vi.fn().mockImplementation(() => ({ getBlockedMcpServers: vi.fn().mockReturnValue([]), getMcpServers: vi.fn().mockReturnValue({}), getLastError: vi.fn().mockReturnValue(undefined), })), getResourceRegistry: vi.fn().mockReturnValue({ getAllResources: vi.fn().mockReturnValue([]), }), setUserInteractedWithMcp: vi.fn(), getLastMcpError: vi.fn().mockReturnValue(undefined), }; mockContext = createMockCommandContext({ services: { agentContext: { config: mockConfig, toolRegistry: mockConfig.getToolRegistry(), }, }, }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('basic functionality', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { agentContext: null, }, }); const result = await mcpCommand.action!(contextWithoutConfig, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not loaded.', }); }); it('should show an error if tool registry is not available', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockContext.services.agentContext as any).toolRegistry = undefined; const result = await mcpCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Could not retrieve tool registry.', }); }); }); describe('with configured MCP servers', () => { beforeEach(() => { const mockMcpServers = { server1: { command: 'cmd1' }, server2: { command: 'cmd2' }, server3: { command: 'cmd3' }, }; mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ getMcpServers: vi.fn().mockReturnValue(mockMcpServers), getBlockedMcpServers: vi.fn().mockReturnValue([]), getLastError: vi.fn().mockReturnValue(undefined), }); }); it('should display configured MCP servers with status indicators and their tools', async () => { // Setup getMCPServerStatus mock implementation vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { if (serverName === 'server1') return MCPServerStatus.CONNECTED; if (serverName === 'server2') return MCPServerStatus.CONNECTED; return MCPServerStatus.DISCONNECTED; // server3 }); // Mock tools from each server using actual DiscoveredMCPTool instances const mockServer1Tools = [ createMockMCPTool('server1_tool1', 'server1'), createMockMCPTool('server1_tool2', 'server1'), ]; const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')]; const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')]; const allTools = [ ...mockServer1Tools, ...mockServer2Tools, ...mockServer3Tools, ]; const mockToolRegistry = { getAllTools: vi.fn().mockReturnValue(allTools), }; mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockContext.services.agentContext as any).toolRegistry = mockToolRegistry; const resourcesByServer: Record< string, Array<{ name: string; uri: string }> > = { server1: [ { name: 'Server1 Resource', uri: 'file:///server1/resource1.txt', }, ], server2: [], server3: [], }; mockConfig.getResourceRegistry = vi.fn().mockReturnValue({ getAllResources: vi.fn().mockReturnValue( Object.entries(resourcesByServer).flatMap(([serverName, resources]) => resources.map((entry) => ({ serverName, ...entry, })), ), ), }); await mcpCommand.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.MCP_STATUS, tools: allTools.map((tool) => ({ serverName: tool.serverName, name: tool.name, description: tool.description, schema: tool.schema, })), resources: expect.arrayContaining([ expect.objectContaining({ serverName: 'server1', uri: 'file:///server1/resource1.txt', }), ]), }), ); }); it('should display tool descriptions when desc argument is used', async () => { const descSubCommand = mcpCommand.subCommands!.find( (c) => c.name === 'desc', ); await descSubCommand!.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.MCP_STATUS, showDescriptions: true, }), ); }); it('should not display descriptions when nodesc argument is used', async () => { const listSubCommand = mcpCommand.subCommands!.find( (c) => c.name === 'list', ); await listSubCommand!.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.MCP_STATUS, showDescriptions: false, }), ); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/mcpCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { SlashCommand, SlashCommandActionReturn, CommandContext, } from './types.js'; import { CommandKind } from './types.js'; import type { MessageActionReturn } from '@google/gemini-cli-core'; import { DiscoveredMCPTool, getMCPDiscoveryState, getMCPServerStatus, MCPDiscoveryState, MCPServerStatus, getErrorMessage, MCPOAuthTokenStorage, mcpServerRequiresOAuth, CoreEvent, coreEvents, } from '@google/gemini-cli-core'; import { MessageType, type HistoryItemMcpStatus } from '../types.js'; import { McpServerEnablementManager, normalizeServerId, canLoadServer, } from '../../config/mcp/mcpServerEnablement.js'; import { loadSettings } from '../../config/settings.js'; const authCommand: SlashCommand = { name: 'auth', description: 'Authenticate with an OAuth-enabled MCP server', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( context: CommandContext, args: string, ): Promise => { const serverName = args.trim(); const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } config.setUserInteractedWithMcp(); const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; if (!serverName) { // List servers that support OAuth from two sources: // 1. Servers with oauth.enabled in config // 2. Servers detected as requiring OAuth (returned 401) const configuredOAuthServers = Object.entries(mcpServers) .filter(([_, server]) => server.oauth?.enabled) .map(([name, _]) => name); const detectedOAuthServers = Array.from( mcpServerRequiresOAuth.keys(), ).filter((name) => mcpServers[name]); // Only include configured servers // Combine and deduplicate const allOAuthServers = [ ...new Set([...configuredOAuthServers, ...detectedOAuthServers]), ]; if (allOAuthServers.length === 0) { return { type: 'message', messageType: 'info', content: 'No MCP servers configured with OAuth authentication.', }; } return { type: 'message', messageType: 'info', content: `MCP servers with OAuth authentication:\n${allOAuthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth to authenticate.`, }; } const server = mcpServers[serverName]; if (!server) { return { type: 'message', messageType: 'error', content: `MCP server '${serverName}' not found.`, }; } // Always attempt OAuth authentication, even if not explicitly configured // The authentication process will discover OAuth requirements automatically const displayListener = (message: string) => { context.ui.addItem({ type: 'info', text: message }); }; coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener); try { context.ui.addItem({ type: 'info', text: `Starting OAuth authentication for MCP server '${serverName}'...`, }); // Import dynamically to avoid circular dependencies const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); let oauthConfig = server.oauth; if (!oauthConfig) { oauthConfig = { enabled: false }; } const mcpServerUrl = server.httpUrl || server.url; const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl); context.ui.addItem({ type: 'info', text: `✅ Successfully authenticated with MCP server '${serverName}'!`, }); // Trigger tool re-discovery to pick up authenticated server const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { context.ui.addItem({ type: 'info', text: `Restarting MCP server '${serverName}'...`, }); await mcpClientManager.restartServer(serverName); } // Update the client with the new tools const geminiClient = context.services.agentContext?.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } // Reload the slash commands to reflect the changes. context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: `Successfully authenticated and reloaded tools for '${serverName}'`, }; } catch (error) { return { type: 'message', messageType: 'error', content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, }; } finally { coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener); } }, completion: async (context: CommandContext, partialArg: string) => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) return []; const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; return Object.keys(mcpServers).filter((name) => name.startsWith(partialArg), ); }, }; const listAction = async ( context: CommandContext, showDescriptions = false, showSchema = false, ): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } config.setUserInteractedWithMcp(); const toolRegistry = agentContext.toolRegistry; if (!toolRegistry) { return { type: 'message', messageType: 'error', content: 'Could not retrieve tool registry.', }; } const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; const serverNames = Object.keys(mcpServers); const blockedMcpServers = config.getMcpClientManager()?.getBlockedMcpServers() || []; const connectingServers = serverNames.filter( (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, ); const discoveryState = getMCPDiscoveryState(); const discoveryInProgress = discoveryState === MCPDiscoveryState.IN_PROGRESS || connectingServers.length > 0; const allTools = toolRegistry.getAllTools(); const mcpTools = allTools.filter((tool) => tool instanceof DiscoveredMCPTool); const promptRegistry = config.getPromptRegistry(); const mcpPrompts = promptRegistry .getAllPrompts() .filter( (prompt) => 'serverName' in prompt && serverNames.includes(prompt.serverName), ); const resourceRegistry = config.getResourceRegistry(); const mcpResources = resourceRegistry .getAllResources() .filter((entry) => serverNames.includes(entry.serverName)); const authStatus: HistoryItemMcpStatus['authStatus'] = {}; const tokenStorage = new MCPOAuthTokenStorage(); for (const serverName of serverNames) { const server = mcpServers[serverName]; // Check auth status for servers with oauth.enabled OR detected as requiring OAuth if (server.oauth?.enabled || mcpServerRequiresOAuth.has(serverName)) { const creds = await tokenStorage.getCredentials(serverName); if (creds) { if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { authStatus[serverName] = 'expired'; } else { authStatus[serverName] = 'authenticated'; } } else { authStatus[serverName] = 'unauthenticated'; } } else { authStatus[serverName] = 'not-configured'; } } // Get enablement state for all servers const enablementManager = McpServerEnablementManager.getInstance(); const enablementState: HistoryItemMcpStatus['enablementState'] = {}; for (const serverName of serverNames) { enablementState[serverName] = await enablementManager.getDisplayState(serverName); } const errors: Record = {}; for (const serverName of serverNames) { const error = config.getMcpClientManager()?.getLastError(serverName); if (error) { errors[serverName] = error; } } const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, servers: mcpServers, tools: mcpTools.map((tool) => ({ serverName: tool.serverName, name: tool.name, description: tool.description, schema: tool.schema, })), prompts: mcpPrompts.map((prompt) => ({ serverName: prompt.serverName, name: prompt.name, description: prompt.description, })), resources: mcpResources.map((resource) => ({ serverName: resource.serverName, name: resource.name, uri: resource.uri, mimeType: resource.mimeType, description: resource.description, })), authStatus, enablementState, errors, blockedServers: blockedMcpServers.map((s) => ({ name: s.name, extensionName: s.extensionName, })), discoveryInProgress, connectingServers, showDescriptions: Boolean(showDescriptions), showSchema: Boolean(showSchema), }; context.ui.addItem(mcpStatusItem); }; const listCommand: SlashCommand = { name: 'list', altNames: ['ls', 'nodesc', 'nodescription'], description: 'List configured MCP servers and tools', kind: CommandKind.BUILT_IN, autoExecute: true, action: (context) => listAction(context), }; const descCommand: SlashCommand = { name: 'desc', altNames: ['description'], description: 'List configured MCP servers and tools with descriptions', kind: CommandKind.BUILT_IN, autoExecute: true, action: (context) => listAction(context, true), }; const schemaCommand: SlashCommand = { name: 'schema', description: 'List configured MCP servers and tools with descriptions and schemas', kind: CommandKind.BUILT_IN, autoExecute: true, action: (context) => listAction(context, true, true), }; const reloadCommand: SlashCommand = { name: 'reload', altNames: ['refresh'], description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( context: CommandContext, ): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } const mcpClientManager = config.getMcpClientManager(); if (!mcpClientManager) { return { type: 'message', messageType: 'error', content: 'Could not retrieve mcp client manager.', }; } context.ui.addItem({ type: 'info', text: 'Reloading MCP servers...', }); await mcpClientManager.restart(); // Update the client with the new tools const geminiClient = agentContext.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } // Reload the slash commands to reflect the changes. context.ui.reloadCommands(); return listCommand.action!(context, ''); }, }; async function handleEnableDisable( context: CommandContext, args: string, enable: boolean, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { return { type: 'message', messageType: 'error', content: 'Config not loaded.', }; } config.setUserInteractedWithMcp(); const parts = args.trim().split(/\s+/); const isSession = parts.includes('--session'); const serverName = parts.filter((p) => p !== '--session')[0]; const action = enable ? 'enable' : 'disable'; if (!serverName) { return { type: 'message', messageType: 'error', content: `Server name required. Usage: /mcp ${action} [--session]`, }; } const name = normalizeServerId(serverName); // Validate server exists const servers = config.getMcpClientManager()?.getMcpServers() || {}; const normalizedServerNames = Object.keys(servers).map(normalizeServerId); if (!normalizedServerNames.includes(name)) { return { type: 'message', messageType: 'error', content: `Server '${serverName}' not found. Use /mcp list to see available servers.`, }; } const manager = McpServerEnablementManager.getInstance(); if (enable) { const settings = loadSettings(); const result = await canLoadServer(name, { adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, allowedList: settings.merged.mcp?.allowed, excludedList: settings.merged.mcp?.excluded, }); if ( !result.allowed && (result.blockType === 'allowlist' || result.blockType === 'excludelist') ) { return { type: 'message', messageType: 'error', content: result.reason ?? 'Blocked by settings.', }; } if (isSession) { manager.clearSessionDisable(name); } else { await manager.enable(name); } if (result.blockType === 'admin') { context.ui.addItem( { type: 'warning', text: 'MCP disabled by admin. Will load when enabled.', }, Date.now(), ); } } else { if (isSession) { manager.disableForSession(name); } else { await manager.disable(name); } } const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`; const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { context.ui.addItem( { type: 'info', text: 'Reloading MCP servers...' }, Date.now(), ); await mcpClientManager.restart(); } if (agentContext.geminiClient?.isInitialized()) await agentContext.geminiClient.setTools(); context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: msg }; } async function getEnablementCompletion( context: CommandContext, partialArg: string, showEnabled: boolean, ): Promise { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) return []; const servers = Object.keys( config.getMcpClientManager()?.getMcpServers() || {}, ); const manager = McpServerEnablementManager.getInstance(); const results: string[] = []; for (const n of servers) { const state = await manager.getDisplayState(n); if (state.enabled === showEnabled && n.startsWith(partialArg)) { results.push(n); } } return results; } const enableCommand: SlashCommand = { name: 'enable', description: 'Enable a disabled MCP server', kind: CommandKind.BUILT_IN, autoExecute: true, action: (ctx, args) => handleEnableDisable(ctx, args, true), completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false), }; const disableCommand: SlashCommand = { name: 'disable', description: 'Disable an MCP server', kind: CommandKind.BUILT_IN, autoExecute: true, action: (ctx, args) => handleEnableDisable(ctx, args, false), completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true), }; export const mcpCommand: SlashCommand = { name: 'mcp', description: 'Manage configured Model Context Protocol (MCP) servers', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ listCommand, descCommand, schemaCommand, authCommand, reloadCommand, enableCommand, disableCommand, ], action: async (context: CommandContext) => listAction(context), }; ================================================ FILE: packages/cli/src/ui/commands/memoryCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { memoryCommand } from './memoryCommand.js'; import type { SlashCommand, CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { refreshMemory, refreshServerHierarchicalMemory, SimpleExtensionLoader, type FileDiscoveryService, showMemory, addMemory, listMemoryFiles, flattenMemory, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, getErrorMessage: vi.fn((error: unknown) => { if (error instanceof Error) return error.message; return String(error); }), refreshMemory: vi.fn(async (config) => { if (config.isJitContextEnabled()) { await config.getContextManager()?.refresh(); const memoryContent = original.flattenMemory(config.getUserMemory()); const fileCount = config.getGeminiMdFileCount() || 0; return { type: 'message', messageType: 'info', content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, }; } return { type: 'message', messageType: 'info', content: 'Memory reloaded successfully.', }; }), showMemory: vi.fn(), addMemory: vi.fn(), listMemoryFiles: vi.fn(), refreshServerHierarchicalMemory: vi.fn(), }; }); const mockRefreshMemory = refreshMemory as Mock; const mockRefreshServerHierarchicalMemory = refreshServerHierarchicalMemory as Mock; describe('memoryCommand', () => { let mockContext: CommandContext; const getSubCommand = ( name: 'show' | 'add' | 'reload' | 'list', ): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, ); if (!subCommand) { throw new Error(`/memory ${name} command not found.`); } return subCommand; }; describe('/memory show', () => { let showCommand: SlashCommand; let mockGetUserMemory: Mock; let mockGetGeminiMdFileCount: Mock; beforeEach(() => { showCommand = getSubCommand('show'); mockGetUserMemory = vi.fn(); mockGetGeminiMdFileCount = vi.fn(); vi.mocked(showMemory).mockImplementation((config) => { const memoryContent = flattenMemory(config.getUserMemory()); const fileCount = config.getGeminiMdFileCount() || 0; let content; if (memoryContent.length > 0) { content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`; } else { content = 'Memory is currently empty.'; } return { type: 'message', messageType: 'info', content, }; }); mockContext = createMockCommandContext({ services: { agentContext: { config: { getUserMemory: mockGetUserMemory, getGeminiMdFileCount: mockGetGeminiMdFileCount, getExtensionLoader: () => new SimpleExtensionLoader([]), }, }, }, }); }); it('should display a message if memory is empty', async () => { if (!showCommand.action) throw new Error('Command has no action'); mockGetUserMemory.mockReturnValue(''); mockGetGeminiMdFileCount.mockReturnValue(0); await showCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Memory is currently empty.', }, expect.any(Number), ); }); it('should display the memory content and file count if it exists', async () => { if (!showCommand.action) throw new Error('Command has no action'); const memoryContent = 'This is a test memory.'; mockGetUserMemory.mockReturnValue(memoryContent); mockGetGeminiMdFileCount.mockReturnValue(1); await showCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`, }, expect.any(Number), ); }); }); describe('/memory add', () => { let addCommand: SlashCommand; beforeEach(() => { addCommand = getSubCommand('add'); vi.mocked(addMemory).mockImplementation((args) => { if (!args || args.trim() === '') { return { type: 'message', messageType: 'error', content: 'Usage: /memory add ', }; } return { type: 'tool', toolName: 'save_memory', toolArgs: { fact: args.trim() }, }; }); mockContext = createMockCommandContext(); }); it('should return an error message if no arguments are provided', () => { if (!addCommand.action) throw new Error('Command has no action'); const result = addCommand.action(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Usage: /memory add ', }); expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); it('should return a tool action and add an info message when arguments are provided', () => { if (!addCommand.action) throw new Error('Command has no action'); const fact = 'remember this'; const result = addCommand.action(mockContext, ` ${fact} `); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `Attempting to save to memory: "${fact}"`, }, expect.any(Number), ); expect(result).toEqual({ type: 'tool', toolName: 'save_memory', toolArgs: { fact }, }); }); }); describe('/memory reload', () => { let reloadCommand: SlashCommand; let mockSetUserMemory: Mock; let mockSetGeminiMdFileCount: Mock; let mockSetGeminiMdFilePaths: Mock; let mockContextManagerRefresh: Mock; beforeEach(() => { reloadCommand = getSubCommand('reload'); mockSetUserMemory = vi.fn(); mockSetGeminiMdFileCount = vi.fn(); mockSetGeminiMdFilePaths = vi.fn(); mockContextManagerRefresh = vi.fn().mockResolvedValue(undefined); const mockConfig = { setUserMemory: mockSetUserMemory, setGeminiMdFileCount: mockSetGeminiMdFileCount, setGeminiMdFilePaths: mockSetGeminiMdFilePaths, getWorkingDir: () => '/test/dir', getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, getExtensionLoader: () => new SimpleExtensionLoader([]), getExtensions: () => [], shouldLoadMemoryFromIncludeDirectories: () => false, getWorkspaceContext: () => ({ getDirectories: () => [], }), getFileFilteringOptions: () => ({ ignore: [], include: [], }), isTrustedFolder: () => false, updateSystemInstructionIfInitialized: vi .fn() .mockResolvedValue(undefined), isJitContextEnabled: vi.fn().mockReturnValue(false), getContextManager: vi.fn().mockReturnValue({ refresh: mockContextManagerRefresh, }), getUserMemory: vi.fn().mockReturnValue(''), getGeminiMdFileCount: vi.fn().mockReturnValue(0), }; mockContext = createMockCommandContext({ services: { agentContext: { config: mockConfig }, settings: { merged: { memoryDiscoveryMaxDirs: 1000, context: { importFormat: 'tree', }, }, } as unknown as LoadedSettings, }, }); mockRefreshMemory.mockClear(); }); it('should use ContextManager.refresh when JIT is enabled', async () => { if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config const config = mockContext.services.agentContext?.config; if (!config) throw new Error('Config is undefined'); vi.mocked(config.isJitContextEnabled).mockReturnValue(true); vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content'); vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3); await reloadCommand.action(mockContext, ''); expect(mockContextManagerRefresh).toHaveBeenCalledOnce(); expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).', }, expect.any(Number), ); }); it('should display success message when memory is reloaded with content (Legacy)', async () => { if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, content: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }; mockRefreshMemory.mockResolvedValue(successMessage); await reloadCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Reloading memory from source files...', }, expect.any(Number), ); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }, expect.any(Number), ); }); it('should display success message when memory is reloaded with no content', async () => { if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, content: 'Memory reloaded successfully. No memory content found.', }; mockRefreshMemory.mockResolvedValue(successMessage); await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Memory reloaded successfully. No memory content found.', }, expect.any(Number), ); }); it('should display an error message if reloading fails', async () => { if (!reloadCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); mockRefreshMemory.mockRejectedValue(error); await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled(); expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, text: `Error reloading memory: ${error.message}`, }, expect.any(Number), ); }); it('should not throw if config service is unavailable', async () => { if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ services: { agentContext: null }, }); await expect( reloadCommand.action(nullConfigContext, ''), ).resolves.toBeUndefined(); expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'Reloading memory from source files...', }, expect.any(Number), ); expect(mockRefreshMemory).not.toHaveBeenCalled(); }); }); describe('/memory list', () => { let listCommand: SlashCommand; let mockGetGeminiMdfilePaths: Mock; beforeEach(() => { listCommand = getSubCommand('list'); mockGetGeminiMdfilePaths = vi.fn(); vi.mocked(listMemoryFiles).mockImplementation((config) => { const filePaths = config.getGeminiMdFilePaths() || []; const fileCount = filePaths.length; let content; if (fileCount > 0) { content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`; } else { content = 'No GEMINI.md files in use.'; } return { type: 'message', messageType: 'info', content, }; }); mockContext = createMockCommandContext({ services: { agentContext: { config: { getGeminiMdFilePaths: mockGetGeminiMdfilePaths, }, }, }, }); }); it('should display a message if no GEMINI.md files are found', async () => { if (!listCommand.action) throw new Error('Command has no action'); mockGetGeminiMdfilePaths.mockReturnValue([]); await listCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: 'No GEMINI.md files in use.', }, expect.any(Number), ); }); it('should display the file count and paths if they exist', async () => { if (!listCommand.action) throw new Error('Command has no action'); const filePaths = ['/path/one/GEMINI.md', '/path/two/GEMINI.md']; mockGetGeminiMdfilePaths.mockReturnValue(filePaths); await listCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, text: `There are 2 GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`, }, expect.any(Number), ); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/memoryCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { addMemory, listMemoryFiles, refreshMemory, showMemory, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import { CommandKind, type SlashCommand, type SlashCommandActionReturn, } from './types.js'; export const memoryCommand: SlashCommand = { name: 'memory', description: 'Commands for interacting with memory', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'show', description: 'Show the current memory contents', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { const config = context.services.agentContext?.config; if (!config) return; const result = showMemory(config); context.ui.addItem( { type: MessageType.INFO, text: result.content, }, Date.now(), ); }, }, { name: 'add', description: 'Add content to the memory', kind: CommandKind.BUILT_IN, autoExecute: false, action: (context, args): SlashCommandActionReturn | void => { const result = addMemory(args); if (result.type === 'message') { return result; } context.ui.addItem( { type: MessageType.INFO, text: `Attempting to save to memory: "${args.trim()}"`, }, Date.now(), ); return result; }, }, { name: 'reload', altNames: ['refresh'], description: 'Reload the memory from the source', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { context.ui.addItem( { type: MessageType.INFO, text: 'Reloading memory from source files...', }, Date.now(), ); try { const config = context.services.agentContext?.config; if (config) { const result = await refreshMemory(config); context.ui.addItem( { type: MessageType.INFO, text: result.content, }, Date.now(), ); } } catch (error) { context.ui.addItem( { type: MessageType.ERROR, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion text: `Error reloading memory: ${(error as Error).message}`, }, Date.now(), ); } }, }, { name: 'list', description: 'Lists the paths of the GEMINI.md files in use', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { const config = context.services.agentContext?.config; if (!config) return; const result = listMemoryFiles(config); context.ui.addItem( { type: MessageType.INFO, text: result.content, }, Date.now(), ); }, }, ], }; ================================================ FILE: packages/cli/src/ui/commands/modelCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { modelCommand } from './modelCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { Config } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; describe('modelCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); }); it('should return a dialog action to open the model dialog when no args', async () => { if (!modelCommand.action) { throw new Error('The model command must have an action.'); } const result = await modelCommand.action(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'model', }); }); it('should call refreshUserQuota if config is available when opening dialog', async () => { if (!modelCommand.action) { throw new Error('The model command must have an action.'); } const mockRefreshUserQuota = vi.fn(); mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, get config() { return this; }, } as unknown as Config; await modelCommand.action(mockContext, ''); expect(mockRefreshUserQuota).toHaveBeenCalled(); }); describe('manage subcommand', () => { it('should return a dialog action to open the model dialog', async () => { const manageCommand = modelCommand.subCommands?.find( (c) => c.name === 'manage', ); expect(manageCommand).toBeDefined(); const result = await manageCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'model', }); }); it('should call refreshUserQuota if config is available', async () => { const manageCommand = modelCommand.subCommands?.find( (c) => c.name === 'manage', ); const mockRefreshUserQuota = vi.fn(); mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, get config() { return this; }, } as unknown as Config; await manageCommand!.action!(mockContext, ''); expect(mockRefreshUserQuota).toHaveBeenCalled(); }); }); describe('set subcommand', () => { it('should set the model and log the command', async () => { const setCommand = modelCommand.subCommands?.find( (c) => c.name === 'set', ); expect(setCommand).toBeDefined(); const mockSetModel = vi.fn(); mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getSessionId: vi.fn().mockReturnValue('test-session'), getContentGeneratorConfig: vi .fn() .mockReturnValue({ authType: 'test-auth' }), isInteractive: vi.fn().mockReturnValue(true), getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), get config() { return this; }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro'); expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', true); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: expect.stringContaining('Model set to gemini-pro'), }), ); }); it('should set the model with persistence when --persist is used', async () => { const setCommand = modelCommand.subCommands?.find( (c) => c.name === 'set', ); const mockSetModel = vi.fn(); mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getSessionId: vi.fn().mockReturnValue('test-session'), getContentGeneratorConfig: vi .fn() .mockReturnValue({ authType: 'test-auth' }), isInteractive: vi.fn().mockReturnValue(true), getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), get config() { return this; }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro --persist'); expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', false); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: expect.stringContaining('Model set to gemini-pro (persisted)'), }), ); }); it('should show error if no model name is provided', async () => { const setCommand = modelCommand.subCommands?.find( (c) => c.name === 'set', ); await setCommand!.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: expect.stringContaining('Usage: /model set '), }), ); }); }); it('should have the correct name and description', () => { expect(modelCommand.name).toBe('model'); expect(modelCommand.description).toBe('Manage model configuration'); }); }); ================================================ FILE: packages/cli/src/ui/commands/modelCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { ModelSlashCommandEvent, logModelSlashCommand, } from '@google/gemini-cli-core'; import { type CommandContext, CommandKind, type SlashCommand, } from './types.js'; import { MessageType } from '../types.js'; const setModelCommand: SlashCommand = { name: 'set', description: 'Set the model to use. Usage: /model set [--persist]', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context: CommandContext, args: string) => { const parts = args.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) { context.ui.addItem({ type: MessageType.ERROR, text: 'Usage: /model set [--persist]', }); return; } const modelName = parts[0]; const persist = parts.includes('--persist'); if (context.services.agentContext?.config) { context.services.agentContext.config.setModel(modelName, !persist); const event = new ModelSlashCommandEvent(modelName); logModelSlashCommand(context.services.agentContext.config, event); context.ui.addItem({ type: MessageType.INFO, text: `Model set to ${modelName}${persist ? ' (persisted)' : ''}`, }); } }, }; const manageModelCommand: SlashCommand = { name: 'manage', description: 'Opens a dialog to configure the model', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { if (context.services.agentContext?.config) { await context.services.agentContext.config.refreshUserQuota(); } return { type: 'dialog', dialog: 'model', }; }, }; export const modelCommand: SlashCommand = { name: 'model', description: 'Manage model configuration', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [manageModelCommand, setModelCommand], action: async (context: CommandContext, args: string) => manageModelCommand.action!(context, args), }; ================================================ FILE: packages/cli/src/ui/commands/oncallCommand.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand, type OpenCustomDialogActionReturn, } from './types.js'; import { TriageDuplicates } from '../components/triage/TriageDuplicates.js'; import { TriageIssues } from '../components/triage/TriageIssues.js'; export const oncallCommand: SlashCommand = { name: 'oncall', description: 'Oncall related commands', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'dedup', description: 'Triage issues labeled as status/possible-duplicate', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } let limit = 50; if (args && args.trim().length > 0) { const argArray = args.trim().split(/\s+/); const parsedLimit = parseInt(argArray[0], 10); if (!isNaN(parsedLimit) && parsedLimit > 0) { limit = parsedLimit; } } return { type: 'custom_dialog', component: ( context.ui.removeComponent()} /> ), }; }, }, { name: 'audit', description: 'Triage issues labeled as status/need-triage', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } let limit = 100; let until: string | undefined; if (args && args.trim().length > 0) { const argArray = args.trim().split(/\s+/); for (let i = 0; i < argArray.length; i++) { const arg = argArray[i]; if (arg === '--until') { if (i + 1 >= argArray.length) { throw new Error('Flag --until requires a value (YYYY-MM-DD).'); } const val = argArray[i + 1]; if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) { throw new Error( `Invalid date format for --until: "${val}". Expected YYYY-MM-DD.`, ); } until = val; i++; } else if (arg.startsWith('--')) { throw new Error(`Unknown flag: ${arg}`); } else { const parsedLimit = parseInt(arg, 10); if (!isNaN(parsedLimit) && parsedLimit > 0) { limit = parsedLimit; } else { throw new Error( `Invalid argument: "${arg}". Expected a positive number or --until flag.`, ); } } } } return { type: 'custom_dialog', component: ( context.ui.removeComponent()} /> ), }; }, }, ], }; ================================================ FILE: packages/cli/src/ui/commands/permissionsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import * as process from 'node:process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { permissionsCommand } from './permissionsCommand.js'; import { type CommandContext, CommandKind } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; vi.mock('node:fs'); describe('permissionsCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); vi.mocked(fs).statSync.mockReturnValue({ isDirectory: vi.fn(() => true), } as unknown as fs.Stats); }); afterEach(() => { vi.restoreAllMocks(); }); it('should have the correct name and description', () => { expect(permissionsCommand.name).toBe('permissions'); expect(permissionsCommand.description).toBe( 'Manage folder trust settings and other permissions', ); }); it('should be a built-in command', () => { expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); }); it('should have a trust subcommand', () => { const trustCommand = permissionsCommand.subCommands?.find( (cmd) => cmd.name === 'trust', ); expect(trustCommand).toBeDefined(); expect(trustCommand?.name).toBe('trust'); expect(trustCommand?.description).toBe( 'Manage folder trust settings. Usage: /permissions trust []', ); expect(trustCommand?.kind).toBe(CommandKind.BUILT_IN); }); it('should return an action to open the permissions dialog with a specified directory', () => { const trustCommand = permissionsCommand.subCommands?.find( (cmd) => cmd.name === 'trust', ); const actionResult = trustCommand?.action?.(mockContext, '/test/dir'); expect(actionResult).toEqual({ type: 'dialog', dialog: 'permissions', props: { targetDirectory: path.resolve('/test/dir'), }, }); }); it('should return an action to open the permissions dialog with the current directory if no path is provided', () => { const trustCommand = permissionsCommand.subCommands?.find( (cmd) => cmd.name === 'trust', ); const actionResult = trustCommand?.action?.(mockContext, ''); expect(actionResult).toEqual({ type: 'dialog', dialog: 'permissions', props: { targetDirectory: process.cwd(), }, }); }); it('should return an error message if the provided path does not exist', () => { const trustCommand = permissionsCommand.subCommands?.find( (cmd) => cmd.name === 'trust', ); vi.mocked(fs).statSync.mockImplementation(() => { throw new Error('ENOENT: no such file or directory'); }); const actionResult = trustCommand?.action?.( mockContext, '/nonexistent/dir', ); expect(actionResult).toEqual({ type: 'message', messageType: 'error', content: `Error accessing path: ${path.resolve( '/nonexistent/dir', )}. ENOENT: no such file or directory`, }); }); it('should return an error message if the provided path is not a directory', () => { const trustCommand = permissionsCommand.subCommands?.find( (cmd) => cmd.name === 'trust', ); vi.mocked(fs).statSync.mockReturnValue({ isDirectory: vi.fn(() => false), } as unknown as fs.Stats); const actionResult = trustCommand?.action?.(mockContext, '/file/not/dir'); expect(actionResult).toEqual({ type: 'message', messageType: 'error', content: `Path is not a directory: ${path.resolve('/file/not/dir')}`, }); }); }); ================================================ FILE: packages/cli/src/ui/commands/permissionsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { OpenDialogActionReturn, SlashCommand, SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import * as process from 'node:process'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { expandHomeDir } from '../utils/directoryUtils.js'; export const permissionsCommand: SlashCommand = { name: 'permissions', description: 'Manage folder trust settings and other permissions', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'trust', description: 'Manage folder trust settings. Usage: /permissions trust []', kind: CommandKind.BUILT_IN, autoExecute: false, action: (context, input): SlashCommandActionReturn => { const dirPath = input.trim(); let targetDirectory: string; if (!dirPath) { targetDirectory = process.cwd(); } else { targetDirectory = path.resolve(expandHomeDir(dirPath)); } try { if (!fs.statSync(targetDirectory).isDirectory()) { return { type: 'message', messageType: 'error', content: `Path is not a directory: ${targetDirectory}`, }; } } catch (e) { const message = e instanceof Error ? e.message : String(e); return { type: 'message', messageType: 'error', content: `Error accessing path: ${targetDirectory}. ${message}`, }; } return { type: 'dialog', dialog: 'permissions', props: { targetDirectory, }, } as OpenDialogActionReturn; }, }, ], action: (context, input): SlashCommandActionReturn => { const parts = input.trim().split(' '); const subcommand = parts[0]; if (!subcommand) { return { type: 'message', messageType: 'error', content: `Please provide a subcommand for /permissions. Usage: /permissions trust []`, }; } return { type: 'message', messageType: 'error', content: `Invalid subcommand for /permissions: ${subcommand}. Usage: /permissions trust []`, }; }, }; ================================================ FILE: packages/cli/src/ui/commands/planCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { planCommand } from './planCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { ApprovalMode, coreEvents, processSingleFileContent, type ProcessedFileReadResult, readFileWithEncoding, } from '@google/gemini-cli-core'; import { copyToClipboard } from '../utils/commandUtils.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitFeedback: vi.fn(), }, processSingleFileContent: vi.fn(), readFileWithEncoding: vi.fn(), partToString: vi.fn((val) => val), }; }); vi.mock('node:path', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, default: { ...actual }, join: vi.fn((...args) => args.join('/')), basename: vi.fn((p) => p.split('/').pop()), }; }); vi.mock('../utils/commandUtils.js', () => ({ copyToClipboard: vi.fn(), })); describe('planCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext({ services: { agentContext: { config: { isPlanEnabled: vi.fn(), setApprovalMode: vi.fn(), getApprovedPlanPath: vi.fn(), getApprovalMode: vi.fn(), getFileSystemService: vi.fn(), storage: { getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, }, ui: { addItem: vi.fn(), }, } as unknown as CommandContext); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should have the correct name and description', () => { expect(planCommand.name).toBe('plan'); expect(planCommand.description).toBe( 'Switch to Plan Mode and view current plan', ); }); it('should switch to plan mode if enabled', async () => { vi.mocked( mockContext.services.agentContext!.config.isPlanEnabled, ).mockReturnValue(true); vi.mocked( mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(undefined); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); expect( mockContext.services.agentContext!.config.setApprovalMode, ).toHaveBeenCalledWith(ApprovalMode.PLAN); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Switched to Plan Mode.', ); }); it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( mockContext.services.agentContext!.config.isPlanEnabled, ).mockReturnValue(true); vi.mocked( mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(mockPlanPath); vi.mocked(processSingleFileContent).mockResolvedValue({ llmContent: '# Approved Plan Content', returnDisplay: '# Approved Plan Content', } as ProcessedFileReadResult); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Approved Plan: approved-plan.md', ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.GEMINI, text: '# Approved Plan Content', }); }); describe('copy subcommand', () => { it('should copy the approved plan to clipboard', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(mockPlanPath); vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); const copySubCommand = planCommand.subCommands?.find( (sc) => sc.name === 'copy', ); if (!copySubCommand?.action) throw new Error('Copy action missing'); await copySubCommand.action(mockContext, ''); expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath); expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content'); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Plan copied to clipboard (approved-plan.md).', ); }); it('should warn if no approved plan is found', async () => { vi.mocked( mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(undefined); const copySubCommand = planCommand.subCommands?.find( (sc) => sc.name === 'copy', ); if (!copySubCommand?.action) throw new Error('Copy action missing'); await copySubCommand.action(mockContext, ''); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'warning', 'No approved plan found to copy.', ); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/planCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandContext, CommandKind, type SlashCommand, } from './types.js'; import { ApprovalMode, coreEvents, debugLogger, processSingleFileContent, partToString, readFileWithEncoding, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import * as path from 'node:path'; import { copyToClipboard } from '../utils/commandUtils.js'; async function copyAction(context: CommandContext) { const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan copy command: config is not available in context'); return; } const planPath = config.getApprovedPlanPath(); if (!planPath) { coreEvents.emitFeedback('warning', 'No approved plan found to copy.'); return; } try { const content = await readFileWithEncoding(planPath); await copyToClipboard(content); coreEvents.emitFeedback( 'info', `Plan copied to clipboard (${path.basename(planPath)}).`, ); } catch (error) { coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error); } } export const planCommand: SlashCommand = { name: 'plan', description: 'Switch to Plan Mode and view current plan', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context) => { const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan command: config is not available in context'); return; } const previousApprovalMode = config.getApprovalMode(); config.setApprovalMode(ApprovalMode.PLAN); if (previousApprovalMode !== ApprovalMode.PLAN) { coreEvents.emitFeedback('info', 'Switched to Plan Mode.'); } const approvedPlanPath = config.getApprovedPlanPath(); if (!approvedPlanPath) { return; } try { const content = await processSingleFileContent( approvedPlanPath, config.storage.getPlansDir(), config.getFileSystemService(), ); const fileName = path.basename(approvedPlanPath); coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`); context.ui.addItem({ type: MessageType.GEMINI, text: partToString(content.llmContent), }); } catch (error) { coreEvents.emitFeedback( 'error', `Failed to read approved plan at ${approvedPlanPath}: ${error}`, error, ); } }, subCommands: [ { name: 'copy', description: 'Copy the currently approved plan to your clipboard', kind: CommandKind.BUILT_IN, autoExecute: true, action: copyAction, }, ], }; ================================================ FILE: packages/cli/src/ui/commands/policiesCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { policiesCommand } from './policiesCommand.js'; import { CommandKind } from './types.js'; import { MessageType } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { type Config, PolicyDecision, ApprovalMode, } from '@google/gemini-cli-core'; describe('policiesCommand', () => { let mockContext: ReturnType; beforeEach(() => { mockContext = createMockCommandContext(); }); it('should have correct command definition', () => { expect(policiesCommand.name).toBe('policies'); expect(policiesCommand.description).toBe('Manage policies'); expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN); expect(policiesCommand.subCommands).toHaveLength(1); expect(policiesCommand.subCommands![0].name).toBe('list'); }); describe('list subcommand', () => { it('should show error if config is missing', async () => { mockContext.services.agentContext = null; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Error: Config not available.', }), expect.any(Number), ); }); it('should show message when no policies are active', async () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue([]), }; mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), get config() { return this; }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'No active policies.', }), expect.any(Number), ); }); it('should list policies grouped by mode', async () => { const mockRules = [ { decision: PolicyDecision.DENY, toolName: 'dangerousTool', priority: 10, }, { decision: PolicyDecision.ALLOW, argsPattern: /safe/, source: 'test.toml', }, { decision: PolicyDecision.ASK_USER, }, ]; const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), get config() { return this; }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: expect.stringContaining('**Active Policies**'), }), expect.any(Number), ); const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; const content = (call[0] as { text: string }).text; expect(content).toContain('### Normal Mode Policies'); expect(content).toContain( '### Auto Edit Mode Policies (combined with normal mode policies)', ); expect(content).toContain( '### Yolo Mode Policies (combined with normal mode policies)', ); expect(content).toContain( '### Plan Mode Policies (combined with normal mode policies)', ); expect(content).toContain( '**DENY** tool: `dangerousTool` [Priority: 10]', ); expect(content).toContain( '**ALLOW** all tools (args match: `safe`) [Source: test.toml]', ); expect(content).toContain('**ASK_USER** all tools'); }); it('should show plan-only rules in plan mode section', async () => { const mockRules = [ { decision: PolicyDecision.ALLOW, toolName: 'glob', priority: 70, modes: [ApprovalMode.PLAN], }, { decision: PolicyDecision.DENY, priority: 60, modes: [ApprovalMode.PLAN], }, { decision: PolicyDecision.ALLOW, toolName: 'shell', priority: 50, }, ]; const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), get config() { return this; }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; const content = (call[0] as { text: string }).text; // Plan-only rules appear under Plan Mode section expect(content).toContain( '### Plan Mode Policies (combined with normal mode policies)', ); // glob ALLOW is plan-only, should appear in plan section expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); // shell ALLOW has no modes (applies to all), appears in normal section expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]'); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/policiesCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; interface CategorizedRules { normal: PolicyRule[]; autoEdit: PolicyRule[]; yolo: PolicyRule[]; plan: PolicyRule[]; } const categorizeRulesByMode = ( rules: readonly PolicyRule[], ): CategorizedRules => { const result: CategorizedRules = { normal: [], autoEdit: [], yolo: [], plan: [], }; const ALL_MODES = Object.values(ApprovalMode); rules.forEach((rule) => { const modes = rule.modes?.length ? rule.modes : ALL_MODES; const modeSet = new Set(modes); if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule); if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule); if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule); if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule); }); return result; }; const formatRule = (rule: PolicyRule, i: number) => `${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` + (rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') + (rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') + (rule.source ? ` [Source: ${rule.source}]` : ''); const formatSection = (title: string, rules: PolicyRule[]) => `### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`; const listPoliciesCommand: SlashCommand = { name: 'list', description: 'List all active policies grouped by mode', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) { context.ui.addItem( { type: MessageType.ERROR, text: 'Error: Config not available.', }, Date.now(), ); return; } const policyEngine = config.getPolicyEngine(); const rules = policyEngine.getRules(); if (rules.length === 0) { context.ui.addItem( { type: MessageType.INFO, text: 'No active policies.', }, Date.now(), ); return; } const categorized = categorizeRulesByMode(rules); const normalRulesSet = new Set(categorized.normal); const uniqueAutoEdit = categorized.autoEdit.filter( (rule) => !normalRulesSet.has(rule), ); const uniqueYolo = categorized.yolo.filter( (rule) => !normalRulesSet.has(rule), ); const uniquePlan = categorized.plan.filter( (rule) => !normalRulesSet.has(rule), ); let content = '**Active Policies**\n\n'; content += formatSection('Normal Mode Policies', categorized.normal); content += formatSection( 'Auto Edit Mode Policies (combined with normal mode policies)', uniqueAutoEdit, ); content += formatSection( 'Yolo Mode Policies (combined with normal mode policies)', uniqueYolo, ); content += formatSection( 'Plan Mode Policies (combined with normal mode policies)', uniquePlan, ); context.ui.addItem( { type: MessageType.INFO, text: content, }, Date.now(), ); }, }; export const policiesCommand: SlashCommand = { name: 'policies', description: 'Manage policies', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [listPoliciesCommand], }; ================================================ FILE: packages/cli/src/ui/commands/privacyCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach } from 'vitest'; import { privacyCommand } from './privacyCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('privacyCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); }); it('should return a dialog action to open the privacy dialog', () => { // Ensure the command has an action to test. if (!privacyCommand.action) { throw new Error('The privacy command must have an action.'); } const result = privacyCommand.action(mockContext, ''); // Assert that the action returns the correct object to trigger the privacy dialog. expect(result).toEqual({ type: 'dialog', dialog: 'privacy', }); }); it('should have the correct name and description', () => { expect(privacyCommand.name).toBe('privacy'); expect(privacyCommand.description).toBe('Display the privacy notice'); }); }); ================================================ FILE: packages/cli/src/ui/commands/privacyCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type OpenDialogActionReturn, type SlashCommand, } from './types.js'; export const privacyCommand: SlashCommand = { name: 'privacy', description: 'Display the privacy notice', kind: CommandKind.BUILT_IN, autoExecute: true, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'privacy', }), }; ================================================ FILE: packages/cli/src/ui/commands/profileCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { isDevelopment } from '../../utils/installationInfo.js'; import { CommandKind, type SlashCommand } from './types.js'; export const profileCommand: SlashCommand | null = isDevelopment ? { name: 'profile', kind: CommandKind.BUILT_IN, description: 'Toggle the debug profile display', autoExecute: true, action: async (context) => { context.ui.toggleDebugProfiler(); return { type: 'message', messageType: 'info', content: 'Toggled profile display.', }; }, } : null; ================================================ FILE: packages/cli/src/ui/commands/quitCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { quitCommand } from './quitCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { formatDuration } from '../utils/formatters.js'; vi.mock('../utils/formatters.js'); describe('quitCommand', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T01:00:00Z')); vi.mocked(formatDuration).mockReturnValue('1h 0m 0s'); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('returns a QuitActionReturn object with the correct messages', () => { const mockContext = createMockCommandContext({ session: { stats: { sessionStartTime: new Date('2025-01-01T00:00:00Z'), }, }, }); if (!quitCommand.action) throw new Error('Action is not defined'); const result = quitCommand.action(mockContext, 'quit'); expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms expect(result).toEqual({ type: 'quit', messages: [ { type: 'user', text: '/quit', id: expect.any(Number), }, { type: 'quit', duration: '1h 0m 0s', id: expect.any(Number), }, ], }); }); }); ================================================ FILE: packages/cli/src/ui/commands/quitCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; export const quitCommand: SlashCommand = { name: 'quit', altNames: ['exit'], description: 'Exit the cli', kind: CommandKind.BUILT_IN, autoExecute: true, action: (context) => { const now = Date.now(); const { sessionStartTime } = context.session.stats; const wallDuration = now - sessionStartTime.getTime(); return { type: 'quit', messages: [ { type: 'user', text: `/quit`, // Keep it consistent, even if /exit was used id: now - 1, }, { type: 'quit', duration: formatDuration(wallDuration), id: now, }, ], }; }, }; ================================================ FILE: packages/cli/src/ui/commands/restoreCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { restoreCommand } from './restoreCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { GEMINI_DIR, type Config, type GitService, } from '@google/gemini-cli-core'; describe('restoreCommand', () => { let mockContext: CommandContext; let mockConfig: Config; let mockGitService: GitService; let mockSetHistory: ReturnType; let testRootDir: string; let geminiTempDir: string; let checkpointsDir: string; beforeEach(async () => { testRootDir = await fs.mkdtemp( path.join(os.tmpdir(), 'restore-command-test-'), ); geminiTempDir = path.join(testRootDir, GEMINI_DIR); checkpointsDir = path.join(geminiTempDir, 'checkpoints'); // The command itself creates this, but for tests it's easier to have it ready. // Some tests might remove it to test error paths. await fs.mkdir(checkpointsDir, { recursive: true }); mockSetHistory = vi.fn().mockResolvedValue(undefined); mockGitService = { restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined), } as unknown as GitService; mockConfig = { getCheckpointingEnabled: vi.fn().mockReturnValue(true), storage: { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, geminiClient: { setHistory: mockSetHistory, }, get config() { return this; }, } as unknown as Config; mockContext = createMockCommandContext({ services: { agentContext: mockConfig, git: mockGitService, }, }); }); afterEach(async () => { vi.restoreAllMocks(); await fs.rm(testRootDir, { recursive: true, force: true }); }); it('should return null if checkpointing is not enabled', () => { vi.mocked(mockConfig.getCheckpointingEnabled).mockReturnValue(false); expect(restoreCommand(mockConfig)).toBeNull(); }); it('should return the command if checkpointing is enabled', () => { expect(restoreCommand(mockConfig)).toEqual( expect.objectContaining({ name: 'restore', description: expect.any(String), action: expect.any(Function), completion: expect.any(Function), }), ); }); describe('action', () => { it('should return an error if temp dir is not found', async () => { vi.mocked( mockConfig.storage.getProjectTempCheckpointsDir, ).mockReturnValue(''); expect( await restoreCommand(mockConfig)?.action?.(mockContext, ''), ).toEqual({ type: 'message', messageType: 'error', content: 'Could not determine the .gemini directory path.', }); }); it('should inform when no checkpoints are found if no args are passed', async () => { // Remove the directory to ensure the command creates it. await fs.rm(checkpointsDir, { recursive: true, force: true }); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, '')).toEqual({ type: 'message', messageType: 'info', content: 'No restorable tool calls found.', }); // Verify the directory was created by the command. await expect(fs.stat(checkpointsDir)).resolves.toBeDefined(); }); it('should list available checkpoints if no args are passed', async () => { await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}'); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, '')).toEqual({ type: 'message', messageType: 'info', content: 'Available tool calls to restore:\n\ntest1\ntest2', }); }); it('should return an error if the specified file is not found', async () => { await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, 'test2')).toEqual({ type: 'message', messageType: 'error', content: 'File not found: test2.json', }); }); it('should handle file read errors gracefully', async () => { const checkpointName = 'test1'; const checkpointPath = path.join( checkpointsDir, `${checkpointName}.json`, ); // Create a directory instead of a file to cause a read error. await fs.mkdir(checkpointPath); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, checkpointName)).toEqual({ type: 'message', messageType: 'error', content: expect.stringContaining( 'Could not read restorable tool calls.', ), }); }); it('should restore a tool call and project state', async () => { const toolCallData = { history: [{ type: 'user', text: 'do a thing', id: 123 }], clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }], commitHash: 'abcdef123', toolCall: { name: 'run_shell_command', args: { command: 'ls' } }, }; await fs.writeFile( path.join(checkpointsDir, 'my-checkpoint.json'), JSON.stringify(toolCallData), ); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({ type: 'tool', toolName: 'run_shell_command', toolArgs: { command: 'ls' }, }); expect(mockContext.ui.loadHistory).toHaveBeenCalledWith( toolCallData.history, ); expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory); expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith( toolCallData.commitHash, ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: 'info', text: 'Restored project to the state before the tool call.', }, expect.any(Number), ); }); it('should restore even if only toolCall is present', async () => { const toolCallData = { toolCall: { name: 'run_shell_command', args: { command: 'ls' } }, }; await fs.writeFile( path.join(checkpointsDir, 'my-checkpoint.json'), JSON.stringify(toolCallData), ); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({ type: 'tool', toolName: 'run_shell_command', toolArgs: { command: 'ls' }, }); expect(mockContext.ui.loadHistory).not.toHaveBeenCalled(); expect(mockSetHistory).not.toHaveBeenCalled(); expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled(); }); }); it('should return an error for a checkpoint file missing the toolCall property', async () => { const checkpointName = 'missing-toolcall'; await fs.writeFile( path.join(checkpointsDir, `${checkpointName}.json`), JSON.stringify({ history: [] }), // An object that is valid JSON but missing the 'toolCall' property ); const command = restoreCommand(mockConfig); expect(await command?.action?.(mockContext, checkpointName)).toEqual({ type: 'message', messageType: 'error', // A more specific error message would be ideal, but for now, we can assert the current behavior. content: expect.stringContaining('Checkpoint file is invalid'), }); }); describe('completion', () => { it('should return an empty array if temp dir is not found', async () => { vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(''); const command = restoreCommand(mockConfig); expect(await command?.completion?.(mockContext, '')).toEqual([]); }); it('should return an empty array on readdir error', async () => { await fs.rm(checkpointsDir, { recursive: true, force: true }); const command = restoreCommand(mockConfig); expect(await command?.completion?.(mockContext, '')).toEqual([]); }); it('should return a list of checkpoint names', async () => { await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}'); await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}'); await fs.writeFile( path.join(checkpointsDir, 'not-a-checkpoint.txt'), '{}', ); const command = restoreCommand(mockConfig); expect(await command?.completion?.(mockContext, '')).toEqual([ 'test1', 'test2', ]); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/restoreCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; import { type Config, formatCheckpointDisplayList, getToolCallDataSchema, getTruncatedCheckpointNames, performRestore, type ToolCallData, } from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, type SlashCommandActionReturn, CommandKind, } from './types.js'; import type { HistoryItem } from '../types.js'; const HistoryItemSchema = z .object({ type: z.string(), id: z.number(), }) .passthrough(); const ToolCallDataSchema = getToolCallDataSchema(HistoryItemSchema); async function restoreAction( context: CommandContext, args: string, ): Promise { const { services, ui } = context; const { agentContext, git: gitService } = services; const { addItem, loadHistory } = ui; const checkpointDir = agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { type: 'message', messageType: 'error', content: 'Could not determine the .gemini directory path.', }; } try { // Ensure the directory exists before trying to read it. await fs.mkdir(checkpointDir, { recursive: true }); const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); if (!args) { if (jsonFiles.length === 0) { return { type: 'message', messageType: 'info', content: 'No restorable tool calls found.', }; } const fileList = formatCheckpointDisplayList(jsonFiles); return { type: 'message', messageType: 'info', content: `Available tool calls to restore:\n\n${fileList}`, }; } const selectedFile = args.endsWith('.json') ? args : `${args}.json`; if (!jsonFiles.includes(selectedFile)) { return { type: 'message', messageType: 'error', content: `File not found: ${selectedFile}`, }; } const filePath = path.join(checkpointDir, selectedFile); const data = await fs.readFile(filePath, 'utf-8'); const parseResult = ToolCallDataSchema.safeParse(JSON.parse(data)); if (!parseResult.success) { return { type: 'message', messageType: 'error', content: `Checkpoint file is invalid: ${parseResult.error.message}`, }; } // We safely cast here because: // 1. ToolCallDataSchema strictly validates the existence of 'history' as an array and 'id'/'type' on each item. // 2. We trust that files valid according to this schema (written by useGeminiStream) contain the full HistoryItem structure. const toolCallData = parseResult.data as ToolCallData< HistoryItem[], Record >; const actionStream = performRestore(toolCallData, gitService); for await (const action of actionStream) { if (action.type === 'message') { addItem( { type: action.messageType, text: action.content, }, Date.now(), ); } else if (action.type === 'load_history' && loadHistory) { loadHistory(action.history); if (action.clientHistory) { agentContext!.geminiClient?.setHistory(action.clientHistory); } } } return { type: 'tool', toolName: toolCallData.toolCall.name, toolArgs: toolCallData.toolCall.args, }; } catch (error) { return { type: 'message', messageType: 'error', content: `Could not read restorable tool calls. This is the error: ${error}`, }; } } async function completion( context: CommandContext, _partialArg: string, ): Promise { const { services } = context; const { agentContext } = services; const checkpointDir = agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } try { const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); return getTruncatedCheckpointNames(jsonFiles); } catch (_err) { return []; } } export const restoreCommand = (config: Config | null): SlashCommand | null => { if (!config?.getCheckpointingEnabled()) { return null; } return { name: 'restore', description: 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', kind: CommandKind.BUILT_IN, autoExecute: true, action: restoreAction, completion, }; }; ================================================ FILE: packages/cli/src/ui/commands/resumeCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, expect, it } from 'vitest'; import { resumeCommand } from './resumeCommand.js'; import type { CommandContext } from './types.js'; describe('resumeCommand', () => { it('should open the session browser for bare /resume', async () => { const result = await resumeCommand.action?.({} as CommandContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'sessionBrowser', }); }); it('should expose unified chat subcommands directly under /resume', () => { const visibleSubCommandNames = (resumeCommand.subCommands ?? []) .filter((subCommand) => !subCommand.hidden) .map((subCommand) => subCommand.name); expect(visibleSubCommandNames).toEqual( expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), ); }); it('should keep a hidden /resume checkpoints compatibility alias', () => { const checkpoints = resumeCommand.subCommands?.find( (subCommand) => subCommand.name === 'checkpoints', ); expect(checkpoints?.hidden).toBe(true); expect( checkpoints?.subCommands?.map((subCommand) => subCommand.name), ).toEqual( expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), ); }); }); ================================================ FILE: packages/cli/src/ui/commands/resumeCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { OpenDialogActionReturn, CommandContext, SlashCommand, } from './types.js'; import { CommandKind } from './types.js'; import { chatResumeSubCommands } from './chatCommand.js'; export const resumeCommand: SlashCommand = { name: 'resume', description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( _context: CommandContext, _args: string, ): Promise => ({ type: 'dialog', dialog: 'sessionBrowser', }), subCommands: chatResumeSubCommands, }; ================================================ FILE: packages/cli/src/ui/commands/rewindCommand.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { rewindCommand } from './rewindCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { waitFor } from '../../test-utils/async.js'; import { RewindOutcome } from '../components/RewindConfirmation.js'; import { type OpenCustomDialogActionReturn, type CommandContext, } from './types.js'; import type { ReactElement } from 'react'; import { coreEvents } from '@google/gemini-cli-core'; // Mock dependencies const mockRewindTo = vi.fn(); const mockRecordMessage = vi.fn(); const mockSetHistory = vi.fn(); const mockSendMessageStream = vi.fn(); const mockGetChatRecordingService = vi.fn(); const mockGetConversation = vi.fn(); const mockRemoveComponent = vi.fn(); const mockLoadHistory = vi.fn(); const mockAddItem = vi.fn(); const mockSetPendingItem = vi.fn(); const mockResetContext = vi.fn(); const mockSetInput = vi.fn(); const mockRevertFileChanges = vi.fn(); const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { ...actual.coreEvents, emitFeedback: vi.fn(), }, logRewind: vi.fn(), RewindEvent: class {}, }; }); vi.mock('../components/RewindViewer.js', () => ({ RewindViewer: () => null, })); vi.mock('../hooks/useSessionBrowser.js', () => ({ convertSessionToHistoryFormats: vi.fn().mockReturnValue({ uiHistory: [ { type: 'user', text: 'old user' }, { type: 'gemini', text: 'old gemini' }, ], clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }], }), })); vi.mock('../utils/rewindFileOps.js', () => ({ revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args), })); interface RewindViewerProps { onRewind: ( messageId: string, newText: string, outcome: RewindOutcome, ) => Promise; conversation: unknown; onExit: () => void; } describe('rewindCommand', () => { let mockContext: CommandContext; beforeEach(() => { vi.clearAllMocks(); mockGetConversation.mockReturnValue({ messages: [{ id: 'msg-1', type: 'user', content: 'hello' }], sessionId: 'test-session', }); mockRewindTo.mockReturnValue({ messages: [], // Mocked rewound messages }); mockGetChatRecordingService.mockReturnValue({ getConversation: mockGetConversation, rewindTo: mockRewindTo, recordMessage: mockRecordMessage, }); mockContext = createMockCommandContext({ services: { agentContext: { geminiClient: { getChatRecordingService: mockGetChatRecordingService, setHistory: mockSetHistory, sendMessageStream: mockSendMessageStream, }, config: { getSessionId: () => 'test-session-id', getContextManager: () => ({ refresh: mockResetContext }), getProjectRoot: mockGetProjectRoot, }, }, }, ui: { removeComponent: mockRemoveComponent, loadHistory: mockLoadHistory, addItem: mockAddItem, setPendingItem: mockSetPendingItem, }, }) as unknown as CommandContext; }); it('should initialize successfully', async () => { const result = await rewindCommand.action!(mockContext, ''); expect(result).toHaveProperty('type', 'custom_dialog'); }); it('should handle RewindOnly correctly', async () => { // 1. Run the command to get the component const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; // Access onRewind from props const onRewind = component.props.onRewind; expect(onRewind).toBeDefined(); await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly); await waitFor(() => { expect(mockRevertFileChanges).not.toHaveBeenCalled(); expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); expect(mockSetHistory).toHaveBeenCalled(); expect(mockResetContext).toHaveBeenCalled(); expect(mockLoadHistory).toHaveBeenCalledWith( [ expect.objectContaining({ text: 'old user', id: 1 }), expect.objectContaining({ text: 'old gemini', id: 2 }), ], 'New Prompt', ); expect(mockRemoveComponent).toHaveBeenCalled(); }); // Verify setInput was NOT called directly (it's handled via loadHistory now) expect(mockSetInput).not.toHaveBeenCalled(); }); it('should handle RewindAndRevert correctly', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onRewind = component.props.onRewind; await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert); await waitFor(() => { expect(mockRevertFileChanges).toHaveBeenCalledWith( mockGetConversation(), 'msg-id-123', ); expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); expect(mockLoadHistory).toHaveBeenCalledWith( expect.any(Array), 'New Prompt', ); }); expect(mockSetInput).not.toHaveBeenCalled(); }); it('should handle RevertOnly correctly', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onRewind = component.props.onRewind; await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly); await waitFor(() => { expect(mockRevertFileChanges).toHaveBeenCalledWith( mockGetConversation(), 'msg-id-123', ); expect(mockRewindTo).not.toHaveBeenCalled(); expect(mockRemoveComponent).toHaveBeenCalled(); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'File changes reverted.', ); }); expect(mockSetInput).not.toHaveBeenCalled(); }); it('should handle Cancel correctly', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onRewind = component.props.onRewind; await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel); await waitFor(() => { expect(mockRevertFileChanges).not.toHaveBeenCalled(); expect(mockRewindTo).not.toHaveBeenCalled(); expect(mockRemoveComponent).toHaveBeenCalled(); }); expect(mockSetInput).not.toHaveBeenCalled(); }); it('should handle onExit correctly', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onExit = component.props.onExit; onExit(); expect(mockRemoveComponent).toHaveBeenCalled(); }); it('should handle rewind error correctly', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onRewind = component.props.onRewind; mockRewindTo.mockImplementation(() => { throw new Error('Rewind Failed'); }); await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); await waitFor(() => { expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'Rewind Failed', ); }); }); it('should handle null conversation from rewindTo', async () => { const result = (await rewindCommand.action!( mockContext, '', )) as OpenCustomDialogActionReturn; const component = result.component as ReactElement; const onRewind = component.props.onRewind; mockRewindTo.mockReturnValue(null); await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); await waitFor(() => { expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'error', 'Could not fetch conversation file', ); expect(mockRemoveComponent).toHaveBeenCalled(); }); }); it('should fail if config is missing', () => { const context = { services: {} } as CommandContext; const result = rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Config not found', }); }); it('should fail if client is not initialized', () => { const context = createMockCommandContext({ services: { agentContext: { geminiClient: undefined, get config() { return this; }, }, }, }) as unknown as CommandContext; const result = rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Client not initialized', }); }); it('should fail if recording service is unavailable', () => { const context = createMockCommandContext({ services: { agentContext: { geminiClient: { getChatRecordingService: () => undefined }, get config() { return this; }, }, }, }) as unknown as CommandContext; const result = rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Recording service unavailable', }); }); it('should return info if no conversation found', () => { mockGetConversation.mockReturnValue(null); const result = rewindCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found.', }); }); it('should return info if no user interactions found', () => { mockGetConversation.mockReturnValue({ messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }], sessionId: 'test-session', }); const result = rewindCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'Nothing to rewind to.', }); }); }); ================================================ FILE: packages/cli/src/ui/commands/rewindCommand.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type CommandContext, type SlashCommand, } from './types.js'; import { RewindViewer } from '../components/RewindViewer.js'; import { type HistoryItem } from '../types.js'; import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js'; import { revertFileChanges } from '../utils/rewindFileOps.js'; import { RewindOutcome } from '../components/RewindConfirmation.js'; import type { Content } from '@google/genai'; import { checkExhaustive, coreEvents, debugLogger, logRewind, RewindEvent, type ChatRecordingService, type GeminiClient, convertSessionToClientHistory, } from '@google/gemini-cli-core'; /** * Helper function to handle the core logic of rewinding a conversation. * This function encapsulates the steps needed to rewind the conversation, * update the client and UI history, and clear the component. * * @param context The command context. * @param client Gemini client * @param recordingService The chat recording service. * @param messageId The ID of the message to rewind to. * @param newText The new text for the input field after rewinding. */ async function rewindConversation( context: CommandContext, client: GeminiClient, recordingService: ChatRecordingService, messageId: string, newText: string, ) { try { const conversation = recordingService.rewindTo(messageId); if (!conversation) { const errorMsg = 'Could not fetch conversation file'; debugLogger.error(errorMsg); context.ui.removeComponent(); coreEvents.emitFeedback('error', errorMsg); return; } // Convert to UI and Client formats const { uiHistory } = convertSessionToHistoryFormats(conversation.messages); const clientHistory = convertSessionToClientHistory(conversation.messages); client.setHistory(clientHistory as Content[]); // Reset context manager as we are rewinding history await context.services.agentContext?.config.getContextManager()?.refresh(); // Update UI History // We generate IDs based on index for the rewind history const startId = 1; const historyWithIds = uiHistory.map( (item, idx) => ({ ...item, id: startId + idx, }) as HistoryItem, ); // 1. Remove component FIRST to avoid flicker and clear the stage context.ui.removeComponent(); // 2. Load the rewound history and set the input context.ui.loadHistory(historyWithIds, newText); } catch (error) { // If an error occurs, we still want to remove the component if possible context.ui.removeComponent(); coreEvents.emitFeedback( 'error', error instanceof Error ? error.message : 'Unknown error during rewind', ); } } export const rewindCommand: SlashCommand = { name: 'rewind', description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, action: (context) => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) return { type: 'message', messageType: 'error', content: 'Config not found', }; const client = agentContext.geminiClient; if (!client) return { type: 'message', messageType: 'error', content: 'Client not initialized', }; const recordingService = client.getChatRecordingService(); if (!recordingService) return { type: 'message', messageType: 'error', content: 'Recording service unavailable', }; const conversation = recordingService.getConversation(); if (!conversation) return { type: 'message', messageType: 'info', content: 'No conversation found.', }; const hasUserInteractions = conversation.messages.some( (msg) => msg.type === 'user', ); if (!hasUserInteractions) { return { type: 'message', messageType: 'info', content: 'Nothing to rewind to.', }; } return { type: 'custom_dialog', component: ( { context.ui.removeComponent(); }} onRewind={async (messageId, newText, outcome) => { if (outcome !== RewindOutcome.Cancel) { logRewind(config, new RewindEvent(outcome)); } switch (outcome) { case RewindOutcome.Cancel: context.ui.removeComponent(); return; case RewindOutcome.RevertOnly: if (conversation) { await revertFileChanges(conversation, messageId); } context.ui.removeComponent(); coreEvents.emitFeedback('info', 'File changes reverted.'); return; case RewindOutcome.RewindAndRevert: if (conversation) { await revertFileChanges(conversation, messageId); } await rewindConversation( context, client, recordingService, messageId, newText, ); return; case RewindOutcome.RewindOnly: await rewindConversation( context, client, recordingService, messageId, newText, ); return; default: checkExhaustive(outcome); } }} /> ), }; }, }; ================================================ FILE: packages/cli/src/ui/commands/settingsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach } from 'vitest'; import { settingsCommand } from './settingsCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('settingsCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); }); it('should return a dialog action to open the settings dialog', () => { if (!settingsCommand.action) { throw new Error('The settings command must have an action.'); } const result = settingsCommand.action(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'settings', }); }); it('should have the correct name and description', () => { expect(settingsCommand.name).toBe('settings'); expect(settingsCommand.description).toBe( 'View and edit Gemini CLI settings', ); }); }); ================================================ FILE: packages/cli/src/ui/commands/settingsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type OpenDialogActionReturn, type SlashCommand, } from './types.js'; export const settingsCommand: SlashCommand = { name: 'settings', description: 'View and edit Gemini CLI settings', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'settings', }), }; ================================================ FILE: packages/cli/src/ui/commands/setupGithubCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; import * as gitUtils from '../../utils/gitUtils.js'; import { setupGithubCommand, updateGitignore, GITHUB_WORKFLOW_PATHS, } from './setupGithubCommand.js'; import type { CommandContext } from './types.js'; import * as commandUtils from '../utils/commandUtils.js'; import { debugLogger, type ToolActionReturn } from '@google/gemini-cli-core'; vi.mock('child_process'); // Mock fetch globally global.fetch = vi.fn(); vi.mock('../../utils/gitUtils.js', () => ({ isGitHubRepository: vi.fn(), getGitRepoRoot: vi.fn(), getLatestGitHubRelease: vi.fn(), getGitHubRepoInfo: vi.fn(), })); vi.mock('../utils/commandUtils.js', () => ({ getUrlOpenCommand: vi.fn(), })); describe('setupGithubCommand', async () => { let scratchDir = ''; beforeEach(async () => { vi.resetAllMocks(); scratchDir = await fs.mkdtemp( path.join(os.tmpdir(), 'setup-github-command-'), ); }); afterEach(async () => { vi.restoreAllMocks(); if (scratchDir) await fs.rm(scratchDir, { recursive: true }); }); it('downloads workflows, updates gitignore, and includes pipefail on non-windows', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); const fakeRepoOwner = 'fake'; const fakeRepoName = 'repo'; const fakeRepoRoot = scratchDir; const fakeReleaseVersion = 'v1.2.3'; const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p)); vi.mocked(global.fetch).mockImplementation(async (url) => { const filename = path.basename(url.toString()); return new Response(filename, { status: 200, statusText: 'OK', headers: { 'Content-Type': 'text/plain' }, }); }); vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( fakeReleaseVersion, ); vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ owner: fakeRepoOwner, repo: fakeRepoName, }); vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce( 'fakeOpenCommand', ); const result = (await setupGithubCommand.action?.( {} as CommandContext, '', )) as ToolActionReturn; const { command } = result.toolArgs; // Check for pipefail expect(command).toContain('set -eEuo pipefail'); // Check that the other commands are still present expect(command).toContain('fakeOpenCommand'); // Verify that the workflows were downloaded for (const workflow of workflows) { const workflowFile = path.join( scratchDir, '.github', 'workflows', workflow, ); const contents = await fs.readFile(workflowFile, 'utf8'); expect(contents).toContain(workflow); } // Verify that .gitignore was created with the expected entries const gitignorePath = path.join(scratchDir, '.gitignore'); const gitignoreExists = await fs .access(gitignorePath) .then(() => true) .catch(() => false); expect(gitignoreExists).toBe(true); if (gitignoreExists) { const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); expect(gitignoreContent).toContain('.gemini/'); expect(gitignoreContent).toContain('gha-creds-*.json'); } }); it('downloads workflows, updates gitignore, and does not include pipefail on windows', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const fakeRepoOwner = 'fake'; const fakeRepoName = 'repo'; const fakeRepoRoot = scratchDir; const fakeReleaseVersion = 'v1.2.3'; const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p)); vi.mocked(global.fetch).mockImplementation(async (url) => { const filename = path.basename(url.toString()); return new Response(filename, { status: 200, statusText: 'OK', headers: { 'Content-Type': 'text/plain' }, }); }); vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( fakeReleaseVersion, ); vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ owner: fakeRepoOwner, repo: fakeRepoName, }); vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce( 'fakeOpenCommand', ); const result = (await setupGithubCommand.action?.( {} as CommandContext, '', )) as ToolActionReturn; const { command } = result.toolArgs; // Check for pipefail expect(command).not.toContain('set -eEuo pipefail'); // Check that the other commands are still present expect(command).toContain('fakeOpenCommand'); // Verify that the workflows were downloaded for (const workflow of workflows) { const workflowFile = path.join( scratchDir, '.github', 'workflows', workflow, ); const contents = await fs.readFile(workflowFile, 'utf8'); expect(contents).toContain(workflow); } // Verify that .gitignore was created with the expected entries const gitignorePath = path.join(scratchDir, '.gitignore'); const gitignoreExists = await fs .access(gitignorePath) .then(() => true) .catch(() => false); expect(gitignoreExists).toBe(true); if (gitignoreExists) { const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); expect(gitignoreContent).toContain('.gemini/'); expect(gitignoreContent).toContain('gha-creds-*.json'); } }); it('throws an error when download fails', async () => { const fakeRepoRoot = scratchDir; const fakeReleaseVersion = 'v1.2.3'; vi.mocked(global.fetch).mockResolvedValue( new Response('Not Found', { status: 404, statusText: 'Not Found', }), ); vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( fakeReleaseVersion, ); vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ owner: 'fake', repo: 'repo', }); await expect( setupGithubCommand.action?.({} as CommandContext, ''), ).rejects.toThrow(/Invalid response code downloading.*404 - Not Found/); }); }); describe('updateGitignore', () => { let scratchDir = ''; beforeEach(async () => { scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'update-gitignore-')); }); afterEach(async () => { if (scratchDir) await fs.rm(scratchDir, { recursive: true }); }); it('creates a new .gitignore file when none exists', async () => { await updateGitignore(scratchDir); const gitignorePath = path.join(scratchDir, '.gitignore'); const content = await fs.readFile(gitignorePath, 'utf8'); expect(content).toBe('.gemini/\ngha-creds-*.json\n'); }); it('appends entries to existing .gitignore file', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = '# Existing content\nnode_modules/\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); const content = await fs.readFile(gitignorePath, 'utf8'); expect(content).toBe( '# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n', ); }); it('does not add duplicate entries', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); const content = await fs.readFile(gitignorePath, 'utf8'); expect(content).toBe(existingContent); }); it('adds only missing entries when some already exist', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = '.gemini/\nsome-other-file\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); const content = await fs.readFile(gitignorePath, 'utf8'); // Should add only the missing gha-creds-*.json entry expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n'); expect(content).toContain('gha-creds-*.json'); // Should not duplicate .gemini/ entry expect((content.match(/\.gemini\//g) || []).length).toBe(1); }); it('does not get confused by entries in comments or as substrings', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = [ '# This is a comment mentioning .gemini/ folder', 'my-app.gemini/config', '# Another comment with gha-creds-*.json pattern', 'some-other-gha-creds-file.json', '', ].join('\n'); await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); const content = await fs.readFile(gitignorePath, 'utf8'); // Should add both entries since they don't actually exist as gitignore rules expect(content).toContain('.gemini/'); expect(content).toContain('gha-creds-*.json'); // Verify the entries were added (not just mentioned in comments) const lines = content .split('\n') .map((line) => line.split('#')[0].trim()) .filter((line) => line); expect(lines).toContain('.gemini/'); expect(lines).toContain('gha-creds-*.json'); expect(lines).toContain('my-app.gemini/config'); expect(lines).toContain('some-other-gha-creds-file.json'); }); it('handles file system errors gracefully', async () => { // Try to update gitignore in a non-existent directory const nonExistentDir = path.join(scratchDir, 'non-existent'); // This should not throw an error await expect(updateGitignore(nonExistentDir)).resolves.toBeUndefined(); }); it('handles permission errors gracefully', async () => { const consoleSpy = vi .spyOn(debugLogger, 'debug') .mockImplementation(() => {}); const fsModule = await import('node:fs'); const writeFileSpy = vi .spyOn(fsModule.promises, 'writeFile') .mockRejectedValue(new Error('Permission denied')); await expect(updateGitignore(scratchDir)).resolves.toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( 'Failed to update .gitignore:', expect.any(Error), ); writeFileSpy.mockRestore(); consoleSpy.mockRestore(); }); }); ================================================ FILE: packages/cli/src/ui/commands/setupGithubCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; import * as fs from 'node:fs'; import { Writable } from 'node:stream'; import { ProxyAgent } from 'undici'; import type { CommandContext } from '../../ui/commands/types.js'; import { getGitRepoRoot, getLatestGitHubRelease, isGitHubRepository, getGitHubRepoInfo, } from '../../utils/gitUtils.js'; import { CommandKind, type SlashCommand, type SlashCommandActionReturn, } from './types.js'; import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; import { debugLogger } from '@google/gemini-cli-core'; export const GITHUB_WORKFLOW_PATHS = [ 'gemini-dispatch/gemini-dispatch.yml', 'gemini-assistant/gemini-invoke.yml', 'gemini-assistant/gemini-plan-execute.yml', 'issue-triage/gemini-triage.yml', 'issue-triage/gemini-scheduled-triage.yml', 'pr-review/gemini-review.yml', ]; export const GITHUB_COMMANDS_PATHS = [ 'gemini-assistant/gemini-invoke.toml', 'gemini-assistant/gemini-plan-execute.toml', 'issue-triage/gemini-scheduled-triage.toml', 'issue-triage/gemini-triage.toml', 'pr-review/gemini-review.toml', ]; const REPO_DOWNLOAD_URL = 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli'; const SOURCE_DIR = 'examples/workflows'; // Generate OS-specific commands to open the GitHub pages needed for setup. function getOpenUrlsCommands(readmeUrl: string): string[] { // Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc const openCmd = getUrlOpenCommand(); // Build a list of URLs to open const urlsToOpen = [readmeUrl]; const repoInfo = getGitHubRepoInfo(); if (repoInfo) { urlsToOpen.push( `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`, ); } // Create and join the individual commands const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`); return commands; } // Add Gemini CLI specific entries to .gitignore file export async function updateGitignore(gitRepoRoot: string): Promise { const gitignoreEntries = ['.gemini/', 'gha-creds-*.json']; const gitignorePath = path.join(gitRepoRoot, '.gitignore'); try { // Check if .gitignore exists and read its content let existingContent = ''; let fileExists = true; try { existingContent = await fs.promises.readFile(gitignorePath, 'utf8'); } catch (_error) { // File doesn't exist fileExists = false; } if (!fileExists) { // Create new .gitignore file with the entries const contentToWrite = gitignoreEntries.join('\n') + '\n'; await fs.promises.writeFile(gitignorePath, contentToWrite); } else { // Check which entries are missing const missingEntries = gitignoreEntries.filter( (entry) => !existingContent .split(/\r?\n/) .some((line) => line.split('#')[0].trim() === entry), ); if (missingEntries.length > 0) { const contentToAdd = '\n' + missingEntries.join('\n') + '\n'; await fs.promises.appendFile(gitignorePath, contentToAdd); } } } catch (error) { debugLogger.debug('Failed to update .gitignore:', error); // Continue without failing the whole command } } async function downloadFiles({ paths, releaseTag, targetDir, proxy, abortController, }: { paths: string[]; releaseTag: string; targetDir: string; proxy: string | undefined; abortController: AbortController; }): Promise { const downloads = []; for (const fileBasename of paths) { downloads.push( (async () => { const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`; const response = await fetch(endpoint, { method: 'GET', dispatcher: proxy ? new ProxyAgent(proxy) : undefined, signal: AbortSignal.any([ AbortSignal.timeout(30_000), abortController.signal, ]), } as RequestInit); if (!response.ok) { throw new Error( `Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`, ); } const body = response.body; if (!body) { throw new Error( `Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`, ); } const destination = path.resolve( targetDir, path.basename(fileBasename), ); const fileStream = fs.createWriteStream(destination, { mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r) flags: 'w', // write and overwrite flush: true, }); await body.pipeTo(Writable.toWeb(fileStream)); })(), ); } await Promise.all(downloads).finally(() => { abortController.abort(); }); } async function createDirectory(dirPath: string): Promise { try { await fs.promises.mkdir(dirPath, { recursive: true }); } catch (_error) { debugLogger.debug(`Failed to create ${dirPath} directory:`, _error); throw new Error( `Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`, ); } } async function downloadSetupFiles({ configs, releaseTag, proxy, }: { configs: Array<{ paths: string[]; targetDir: string }>; releaseTag: string; proxy: string | undefined; }): Promise { try { await Promise.all( configs.map(({ paths, targetDir }) => { const abortController = new AbortController(); return downloadFiles({ paths, releaseTag, targetDir, proxy, abortController, }); }), ); } catch (error) { debugLogger.debug('Failed to download required setup files: ', error); throw error; } } export const setupGithubCommand: SlashCommand = { name: 'setup-github', description: 'Set up GitHub Actions', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( context: CommandContext, ): Promise => { if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); } // Find the root directory of the repo let gitRepoRoot: string; try { gitRepoRoot = getGitRepoRoot(); } catch (_error) { debugLogger.debug(`Failed to get git repo root:`, _error); throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); } // Get the latest release tag from GitHub const proxy = context?.services?.agentContext?.config.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; // Create workflows directory const workflowsDir = path.join(gitRepoRoot, '.github', 'workflows'); await createDirectory(workflowsDir); // Create commands directory const commandsDir = path.join(gitRepoRoot, '.github', 'commands'); await createDirectory(commandsDir); await downloadSetupFiles({ configs: [ { paths: GITHUB_WORKFLOW_PATHS, targetDir: workflowsDir }, { paths: GITHUB_COMMANDS_PATHS, targetDir: commandsDir }, ], releaseTag, proxy, }); // Add entries to .gitignore file await updateGitignore(gitRepoRoot); // Print out a message const commands = []; if (process.platform !== 'win32') { commands.push('set -eEuo pipefail'); } commands.push( `echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows , ${GITHUB_COMMANDS_PATHS.length} commands and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`, ); commands.push(...getOpenUrlsCommands(readmeUrl)); const command = `(${commands.join(' && ')})`; return { type: 'tool', toolName: 'run_shell_command', toolArgs: { description: 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', command, }, }; }, }; ================================================ FILE: packages/cli/src/ui/commands/shellsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi } from 'vitest'; import { shellsCommand } from './shellsCommand.js'; import type { CommandContext } from './types.js'; describe('shellsCommand', () => { it('should call toggleBackgroundShell', async () => { const toggleBackgroundShell = vi.fn(); const context = { ui: { toggleBackgroundShell, }, } as unknown as CommandContext; if (shellsCommand.action) { await shellsCommand.action(context, ''); } expect(toggleBackgroundShell).toHaveBeenCalled(); }); it('should have correct name and altNames', () => { expect(shellsCommand.name).toBe('shells'); expect(shellsCommand.altNames).toContain('bashes'); }); it('should auto-execute', () => { expect(shellsCommand.autoExecute).toBe(true); }); }); ================================================ FILE: packages/cli/src/ui/commands/shellsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; export const shellsCommand: SlashCommand = { name: 'shells', altNames: ['bashes'], kind: CommandKind.BUILT_IN, description: 'Toggle background shells view', autoExecute: true, action: async (context) => { context.ui.toggleBackgroundShell(); }, }; ================================================ FILE: packages/cli/src/ui/commands/shortcutsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; export const shortcutsCommand: SlashCommand = { name: 'shortcuts', altNames: [], kind: CommandKind.BUILT_IN, description: 'Toggle the shortcuts panel above the input', autoExecute: true, action: (context) => { context.ui.toggleShortcutsHelp(); }, }; ================================================ FILE: packages/cli/src/ui/commands/skillsCommand.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { skillsCommand } from './skillsCommand.js'; import { MessageType, type HistoryItemSkillsList } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; import type { Config, SkillDefinition } from '@google/gemini-cli-core'; import { SettingScope, type LoadedSettings, createTestMergedSettings, type MergedSettings, } from '../../config/settings.js'; vi.mock('../../utils/skillUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, linkSkill: vi.fn(), }; }); vi.mock('../../config/extensions/consent.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, requestConsentInteractive: vi.fn().mockResolvedValue(true), skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'), }; }); import { linkSkill } from '../../utils/skillUtils.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), }; }); describe('skillsCommand', () => { let context: CommandContext; beforeEach(() => { vi.useFakeTimers(); const skills = [ { name: 'skill1', description: 'desc1', location: '/loc1', body: 'body1', }, { name: 'skill2', description: 'desc2', location: '/loc2', body: 'body2', }, ]; context = createMockCommandContext({ services: { agentContext: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), getSkills: vi.fn().mockReturnValue(skills), isAdminEnabled: vi.fn().mockReturnValue(true), getSkill: vi .fn() .mockImplementation( (name: string) => skills.find((s) => s.name === name) ?? null, ), }), getContentGenerator: vi.fn(), get config() { return this; }, } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), workspace: { path: '/workspace' }, setValue: vi.fn(), } as unknown as LoadedSettings, }, }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('should add a SKILLS_LIST item to UI with descriptions by default', async () => { await skillsCommand.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.SKILLS_LIST, skills: [ { name: 'skill1', description: 'desc1', disabled: undefined, location: '/loc1', body: 'body1', }, { name: 'skill2', description: 'desc2', disabled: undefined, location: '/loc2', body: 'body2', }, ], showDescriptions: true, }), ); }); it('should list skills when "list" subcommand is used', async () => { const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; await listCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.SKILLS_LIST, skills: [ { name: 'skill1', description: 'desc1', disabled: undefined, location: '/loc1', body: 'body1', }, { name: 'skill2', description: 'desc2', disabled: undefined, location: '/loc2', body: 'body2', }, ], showDescriptions: true, }), ); }); it('should disable descriptions if "nodesc" arg is provided to list', async () => { const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; await listCmd.action!(context, 'nodesc'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ showDescriptions: false, }), ); }); it('should filter built-in skills by default and show them with "all"', async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'regular', description: 'desc1', location: '/loc1', body: 'body1', }, { name: 'builtin', description: 'desc2', location: '/loc2', body: 'body2', isBuiltin: true, }, ]; vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; // By default, only regular skills await listCmd.action!(context, ''); let lastCall = vi .mocked(context.ui.addItem) .mock.calls.at(-1)![0] as HistoryItemSkillsList; expect(lastCall.skills).toHaveLength(1); expect(lastCall.skills[0].name).toBe('regular'); // With "all", show both await listCmd.action!(context, 'all'); lastCall = vi .mocked(context.ui.addItem) .mock.calls.at(-1)![0] as HistoryItemSkillsList; expect(lastCall.skills).toHaveLength(2); expect(lastCall.skills.map((s) => s.name)).toContain('builtin'); // With "--all", show both await listCmd.action!(context, '--all'); lastCall = vi .mocked(context.ui.addItem) .mock.calls.at(-1)![0] as HistoryItemSkillsList; expect(lastCall.skills).toHaveLength(2); }); describe('link', () => { it('should link a skill successfully', async () => { const linkCmd = skillsCommand.subCommands!.find( (s) => s.name === 'link', )!; vi.mocked(linkSkill).mockResolvedValue([ { name: 'test-skill', location: '/path' }, ]); await linkCmd.action!(context, '/some/path'); expect(linkSkill).toHaveBeenCalledWith( '/some/path', 'user', expect.any(Function), expect.any(Function), ); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Successfully linked skills from "/some/path" (user).', }), ); }); it('should link a skill with workspace scope', async () => { const linkCmd = skillsCommand.subCommands!.find( (s) => s.name === 'link', )!; vi.mocked(linkSkill).mockResolvedValue([ { name: 'test-skill', location: '/path' }, ]); await linkCmd.action!(context, '/some/path --scope workspace'); expect(linkSkill).toHaveBeenCalledWith( '/some/path', 'workspace', expect.any(Function), expect.any(Function), ); }); it('should show error if link fails', async () => { const linkCmd = skillsCommand.subCommands!.find( (s) => s.name === 'link', )!; vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed')); await linkCmd.action!(context, '/some/path'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Failed to link skills: Link failed', }), ); }); it('should show error if path is missing', async () => { const linkCmd = skillsCommand.subCommands!.find( (s) => s.name === 'link', )!; await linkCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Usage: /skills link [--scope user|workspace]', }), ); }); }); describe('disable/enable', () => { beforeEach(() => { ( context.services.settings as unknown as { merged: MergedSettings } ).merged = createTestMergedSettings({ skills: { enabled: true, disabled: [] }, }); ( context.services.settings as unknown as { workspace: { path: string } } ).workspace = { path: '/workspace', }; interface MockSettings { user: { settings: unknown; path: string }; workspace: { settings: unknown; path: string }; forScope: unknown; } const settings = context.services.settings as unknown as MockSettings; settings.forScope = vi.fn((scope) => { if (scope === SettingScope.User) return settings.user; if (scope === SettingScope.Workspace) return settings.workspace; return { settings: {}, path: '' }; }); settings.user = { settings: {}, path: '/user/settings.json', }; settings.workspace = { settings: {}, path: '/workspace', }; }); it('should disable a skill', async () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; await disableCmd.action!(context, 'skill1'); expect(context.services.settings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'skills.disabled', ['skill1'], ); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace) settings. You can run "/skills reload" to refresh your current instance.', }), ); }); it('should show reload guidance even if skill is already disabled', async () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; ( context.services.settings as unknown as { merged: MergedSettings } ).merged = createTestMergedSettings({ skills: { enabled: true, disabled: ['skill1'] }, }); ( context.services.settings as unknown as { workspace: { settings: { skills: { disabled: string[] } } }; } ).workspace.settings = { skills: { disabled: ['skill1'] }, }; await disableCmd.action!(context, 'skill1'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Skill "skill1" is already disabled. You can run "/skills reload" to refresh your current instance.', }), ); }); it('should enable a skill', async () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; ( context.services.settings as unknown as { merged: MergedSettings } ).merged = createTestMergedSettings({ skills: { enabled: true, disabled: ['skill1'], }, }); ( context.services.settings as unknown as { workspace: { settings: { skills: { disabled: string[] } } }; } ).workspace.settings = { skills: { disabled: ['skill1'] }, }; await enableCmd.action!(context, 'skill1'); expect(context.services.settings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'skills.disabled', [], ); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', }), ); }); it('should enable a skill across multiple scopes', async () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; ( context.services.settings as unknown as { user: { settings: { skills: { disabled: string[] } } }; } ).user.settings = { skills: { disabled: ['skill1'] }, }; ( context.services.settings as unknown as { workspace: { settings: { skills: { disabled: string[] } } }; } ).workspace.settings = { skills: { disabled: ['skill1'] }, }; await enableCmd.action!(context, 'skill1'); expect(context.services.settings.setValue).toHaveBeenCalledWith( SettingScope.User, 'skills.disabled', [], ); expect(context.services.settings.setValue).toHaveBeenCalledWith( SettingScope.Workspace, 'skills.disabled', [], ); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', }), ); }); it('should show error if skill not found during disable', async () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; await disableCmd.action!(context, 'non-existent'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Skill "non-existent" not found.', }), expect.any(Number), ); }); it('should show error if skills are disabled by admin during disable', async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; await disableCmd.action!(context, 'skill1'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); }); it('should show error if skills are disabled by admin during enable', async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; await enableCmd.action!(context, 'skill1'); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); }); }); describe('reload', () => { it('should reload skills successfully and show success message', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; // Make reload take some time so timer can fire const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); // Initially, no pending item (flicker prevention) expect(context.ui.setPendingItem).not.toHaveBeenCalled(); // Fast forward 100ms to trigger the pending item await vi.advanceTimersByTimeAsync(100); expect(context.ui.setPendingItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Reloading agent skills...', }), ); // Fast forward another 100ms (reload complete), but pending item should stay await vi.advanceTimersByTimeAsync(100); expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null); // Fast forward to reach 500ms total await vi.advanceTimersByTimeAsync(300); await actionPromise; expect(reloadSkillsMock).toHaveBeenCalled(); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Agent skills reloaded successfully.', }), ); }); it('should show new skills count after reload', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, { name: 'skill2' }, { name: 'skill3' }, ] as SkillDefinition[]); }); context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Agent skills reloaded successfully. 1 newly available skill.', }), ); }); it('should show removed skills count after reload', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, ] as SkillDefinition[]); }); context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Agent skills reloaded successfully. 1 skill no longer available.', }), ); }); it('should show both added and removed skills count after reload', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { const skillManager = context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill2' }, // skill1 removed, skill3 added { name: 'skill3' }, ] as SkillDefinition[]); }); context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.', }), ); }); it('should show error if configuration is missing', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; context.services.agentContext = null; await reloadCmd.action!(context, ''); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Could not retrieve configuration.', }), ); }); it('should show error if reload fails', async () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; const error = new Error('Reload failed'); const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((_, reject) => setTimeout(() => reject(error), 200)); }); context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(400); await actionPromise; expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, text: 'Failed to reload skills: Reload failed', }), ); }); }); describe('completions', () => { it('should provide completions for disable (only enabled skills)', async () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; const skillManager = context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', description: 'desc1', disabled: false, location: '/loc1', body: 'body1', }, { name: 'skill2', description: 'desc2', disabled: true, location: '/loc2', body: 'body2', }, ]; vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); vi.mocked(skillManager.getSkill).mockImplementation( (name: string) => mockSkills.find((s) => s.name === name) ?? null, ); const completions = await disableCmd.completion!(context, 'sk'); expect(completions).toEqual(['skill1']); }); it('should provide completions for enable (only disabled skills)', async () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; const skillManager = context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', description: 'desc1', disabled: false, location: '/loc1', body: 'body1', }, { name: 'skill2', description: 'desc2', disabled: true, location: '/loc2', body: 'body2', }, ]; vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); vi.mocked(skillManager.getSkill).mockImplementation( (name: string) => mockSkills.find((s) => s.name === name) ?? null, ); const completions = await enableCmd.completion!(context, 'sk'); expect(completions).toEqual(['skill2']); }); }); }); ================================================ FILE: packages/cli/src/ui/commands/skillsCommand.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandContext, type SlashCommand, type SlashCommandActionReturn, CommandKind, } from './types.js'; import { type HistoryItemInfo, type HistoryItemSkillsList, MessageType, } from '../types.js'; import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core'; import { linkSkill, renderSkillActionFeedback, } from '../../utils/skillUtils.js'; import { SettingScope } from '../../config/settings.js'; import { requestConsentInteractive, skillsConsentString, } from '../../config/extensions/consent.js'; async function listAction( context: CommandContext, args: string, ): Promise { const subArgs = args.trim().split(/\s+/); // Default to SHOWING descriptions. The user can hide them with 'nodesc'. let useShowDescriptions = true; let showAll = false; for (const arg of subArgs) { if (arg === 'nodesc' || arg === '--nodesc') { useShowDescriptions = false; } else if (arg === 'all' || arg === '--all') { showAll = true; } } const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { context.ui.addItem({ type: MessageType.ERROR, text: 'Could not retrieve skill manager.', }); return; } const skills = showAll ? skillManager.getAllSkills() : skillManager.getAllSkills().filter((s) => !s.isBuiltin); const skillsListItem: HistoryItemSkillsList = { type: MessageType.SKILLS_LIST, skills: skills.map((skill) => ({ name: skill.name, description: skill.description, disabled: skill.disabled, location: skill.location, body: skill.body, isBuiltin: skill.isBuiltin, })), showDescriptions: useShowDescriptions, }; context.ui.addItem(skillsListItem); } async function linkAction( context: CommandContext, args: string, ): Promise { const parts = args.trim().split(/\s+/); const sourcePath = parts[0]; if (!sourcePath) { context.ui.addItem({ type: MessageType.ERROR, text: 'Usage: /skills link [--scope user|workspace]', }); return; } let scopeArg = 'user'; if (parts.length >= 3 && parts[1] === '--scope') { scopeArg = parts[2]; } else if (parts.length >= 2 && parts[1].startsWith('--scope=')) { scopeArg = parts[1].split('=')[1]; } const scope = scopeArg === 'workspace' ? 'workspace' : 'user'; try { await linkSkill( sourcePath, scope, (msg) => context.ui.addItem({ type: MessageType.INFO, text: msg, }), async (skills, targetDir) => { const consentString = await skillsConsentString( skills, sourcePath, targetDir, true, ); return requestConsentInteractive( consentString, context.ui.setConfirmationRequest.bind(context.ui), ); }, ); context.ui.addItem({ type: MessageType.INFO, text: `Successfully linked skills from "${sourcePath}" (${scope}).`, }); if (context.services.agentContext?.config) { await context.services.agentContext.config.reloadSkills(); } } catch (error) { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to link skills: ${getErrorMessage(error)}`, }); } } async function disableAction( context: CommandContext, args: string, ): Promise { const skillName = args.trim(); if (!skillName) { context.ui.addItem({ type: MessageType.ERROR, text: 'Please provide a skill name to disable.', }); return; } const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', context.services.agentContext?.config ?? undefined, ), }, Date.now(), ); return; } const skill = skillManager?.getSkill(skillName); if (!skill) { context.ui.addItem( { type: MessageType.ERROR, text: `Skill "${skillName}" not found.`, }, Date.now(), ); return; } const scope = context.services.settings.workspace.path ? SettingScope.Workspace : SettingScope.User; const result = disableSkill(context.services.settings, skillName, scope); let feedback = renderSkillActionFeedback( result, (label, path) => `${label} (${path})`, ); if (result.status === 'success' || result.status === 'no-op') { feedback += ' You can run "/skills reload" to refresh your current instance.'; } context.ui.addItem({ type: MessageType.INFO, text: feedback, }); } async function enableAction( context: CommandContext, args: string, ): Promise { const skillName = args.trim(); if (!skillName) { context.ui.addItem({ type: MessageType.ERROR, text: 'Please provide a skill name to enable.', }); return; } const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', context.services.agentContext?.config ?? undefined, ), }, Date.now(), ); return; } const result = enableSkill(context.services.settings, skillName); let feedback = renderSkillActionFeedback( result, (label, path) => `${label} (${path})`, ); if (result.status === 'success' || result.status === 'no-op') { feedback += ' You can run "/skills reload" to refresh your current instance.'; } context.ui.addItem({ type: MessageType.INFO, text: feedback, }); } async function reloadAction( context: CommandContext, ): Promise { const config = context.services.agentContext?.config; if (!config) { context.ui.addItem({ type: MessageType.ERROR, text: 'Could not retrieve configuration.', }); return; } const skillManager = config.getSkillManager(); const beforeNames = new Set(skillManager.getSkills().map((s) => s.name)); const startTime = Date.now(); let pendingItemSet = false; const pendingTimeout = setTimeout(() => { context.ui.setPendingItem({ type: MessageType.INFO, text: 'Reloading agent skills...', }); pendingItemSet = true; }, 100); try { await config.reloadSkills(); clearTimeout(pendingTimeout); if (pendingItemSet) { // If we showed the pending item, make sure it stays for at least 500ms // total to avoid a "flicker" where it appears and immediately disappears. const elapsed = Date.now() - startTime; const minVisibleDuration = 500; if (elapsed < minVisibleDuration) { await new Promise((resolve) => setTimeout(resolve, minVisibleDuration - elapsed), ); } context.ui.setPendingItem(null); } const afterSkills = skillManager.getSkills(); const afterNames = new Set(afterSkills.map((s) => s.name)); const added = afterSkills.filter((s) => !beforeNames.has(s.name)); const removedCount = [...beforeNames].filter( (name) => !afterNames.has(name), ).length; let successText = 'Agent skills reloaded successfully.'; const details: string[] = []; if (added.length > 0) { details.push( `${added.length} newly available skill${added.length > 1 ? 's' : ''}`, ); } if (removedCount > 0) { details.push( `${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`, ); } if (details.length > 0) { successText += ` ${details.join(' and ')}.`; } context.ui.addItem({ type: 'info', text: successText, icon: '✓ ', color: 'green', } as HistoryItemInfo); } catch (error) { clearTimeout(pendingTimeout); if (pendingItemSet) { context.ui.setPendingItem(null); } context.ui.addItem({ type: MessageType.ERROR, text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`, }); } } function disableCompletion( context: CommandContext, partialArg: string, ): string[] { const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } return skillManager .getAllSkills() .filter((s) => !s.disabled && s.name.startsWith(partialArg)) .map((s) => s.name); } function enableCompletion( context: CommandContext, partialArg: string, ): string[] { const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } return skillManager .getAllSkills() .filter((s) => s.disabled && s.name.startsWith(partialArg)) .map((s) => s.name); } export const skillsCommand: SlashCommand = { name: 'skills', description: 'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable | enable | reload]', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'list', description: 'List available agent skills. Usage: /skills list [nodesc] [all]', kind: CommandKind.BUILT_IN, action: listAction, }, { name: 'link', description: 'Link an agent skill from a local path. Usage: /skills link [--scope user|workspace]', kind: CommandKind.BUILT_IN, action: linkAction, }, { name: 'disable', description: 'Disable a skill by name. Usage: /skills disable ', kind: CommandKind.BUILT_IN, action: disableAction, completion: disableCompletion, }, { name: 'enable', description: 'Enable a disabled skill by name. Usage: /skills enable ', kind: CommandKind.BUILT_IN, action: enableAction, completion: enableCompletion, }, { name: 'reload', altNames: ['refresh'], description: 'Reload the list of discovered skills. Usage: /skills reload', kind: CommandKind.BUILT_IN, action: reloadAction, }, ], action: listAction, }; ================================================ FILE: packages/cli/src/ui/commands/statsCommand.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { statsCommand } from './statsCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; import type { Config } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, UserAccountManager: vi.fn().mockImplementation(() => ({ getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), })), getG1CreditBalance: vi.fn().mockReturnValue(undefined), }; }); describe('statsCommand', () => { let mockContext: CommandContext; const startTime = new Date('2025-07-14T10:00:00.000Z'); const endTime = new Date('2025-07-14T10:00:30.000Z'); beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(endTime); // 1. Create the mock context with all default values mockContext = createMockCommandContext(); // 2. Directly set the property on the created mock context mockContext.session.stats.sessionStartTime = startTime; }); it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); mockContext.services.agentContext = { refreshUserQuota: vi.fn(), refreshAvailableCredits: vi.fn(), getUserTierName: vi.fn(), getUserPaidTier: vi.fn(), getModel: vi.fn(), get config() { return this; }, } as unknown as Config; await statsCommand.action(mockContext, ''); const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.STATS, duration: expectedDuration, selectedAuthType: '', tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, creditBalance: undefined, }); }); it('should fetch and display quota if config is available', async () => { if (!statsCommand.action) throw new Error('Command has no action'); const mockQuota = { buckets: [] }; const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota); const mockGetUserTierName = vi.fn().mockReturnValue('Basic'); const mockGetModel = vi.fn().mockReturnValue('gemini-pro'); const mockGetQuotaRemaining = vi.fn().mockReturnValue(85); const mockGetQuotaLimit = vi.fn().mockReturnValue(100); const mockGetQuotaResetTime = vi .fn() .mockReturnValue('2025-01-01T12:00:00Z'); mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, getModel: mockGetModel, getQuotaRemaining: mockGetQuotaRemaining, getQuotaLimit: mockGetQuotaLimit, getQuotaResetTime: mockGetQuotaResetTime, getUserPaidTier: vi.fn(), refreshAvailableCredits: vi.fn(), get config() { return this; }, } as unknown as Config; await statsCommand.action(mockContext, ''); expect(mockRefreshUserQuota).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ quotas: mockQuota, tier: 'Basic', currentModel: 'gemini-pro', pooledRemaining: 85, pooledLimit: 100, pooledResetTime: '2025-01-01T12:00:00Z', }), ); }); it('should display model stats when using the "model" subcommand', () => { const modelSubCommand = statsCommand.subCommands?.find( (sc) => sc.name === 'model', ); if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); // eslint-disable-next-line @typescript-eslint/no-floating-promises modelSubCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.MODEL_STATS, selectedAuthType: '', tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, pooledRemaining: undefined, pooledLimit: undefined, }); }); it('should display tool stats when using the "tools" subcommand', () => { const toolsSubCommand = statsCommand.subCommands?.find( (sc) => sc.name === 'tools', ); if (!toolsSubCommand?.action) throw new Error('Subcommand has no action'); // eslint-disable-next-line @typescript-eslint/no-floating-promises toolsSubCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.TOOL_STATS, }); }); }); ================================================ FILE: packages/cli/src/ui/commands/statsCommand.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { HistoryItemStats, HistoryItemModelStats, HistoryItemToolStats, } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; import { UserAccountManager, getG1CreditBalance, } from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; function getUserIdentity(context: CommandContext) { const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; const userAccountManager = new UserAccountManager(); const cachedAccount = userAccountManager.getCachedGoogleAccount(); const userEmail = cachedAccount ?? undefined; const tier = context.services.agentContext?.config.getUserTierName(); const paidTier = context.services.agentContext?.config.getUserPaidTier(); const creditBalance = getG1CreditBalance(paidTier) ?? undefined; return { selectedAuthType, userEmail, tier, creditBalance }; } async function defaultSessionView(context: CommandContext) { const now = new Date(); const { sessionStartTime } = context.session.stats; if (!sessionStartTime) { context.ui.addItem({ type: MessageType.ERROR, text: 'Session start time is unavailable, cannot calculate stats.', }); return; } const wallDuration = now.getTime() - sessionStartTime.getTime(); const { selectedAuthType, userEmail, tier, creditBalance } = getUserIdentity(context); const currentModel = context.services.agentContext?.config.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration), selectedAuthType, userEmail, tier, currentModel, creditBalance, }; if (context.services.agentContext?.config) { const [quota] = await Promise.all([ context.services.agentContext.config.refreshUserQuota(), context.services.agentContext.config.refreshAvailableCredits(), ]); if (quota) { statsItem.quotas = quota; statsItem.pooledRemaining = context.services.agentContext.config.getQuotaRemaining(); statsItem.pooledLimit = context.services.agentContext.config.getQuotaLimit(); statsItem.pooledResetTime = context.services.agentContext.config.getQuotaResetTime(); } } context.ui.addItem(statsItem); } export const statsCommand: SlashCommand = { name: 'stats', altNames: ['usage'], description: 'Check session stats. Usage: /stats [session|model|tools]', kind: CommandKind.BUILT_IN, autoExecute: false, isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, subCommands: [ { name: 'session', description: 'Show session-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, }, { name: 'model', description: 'Show model-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); const currentModel = context.services.agentContext?.config.getModel(); const pooledRemaining = context.services.agentContext?.config.getQuotaRemaining(); const pooledLimit = context.services.agentContext?.config.getQuotaLimit(); const pooledResetTime = context.services.agentContext?.config.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, userEmail, tier, currentModel, pooledRemaining, pooledLimit, pooledResetTime, } as HistoryItemModelStats); }, }, { name: 'tools', description: 'Show tool-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: (context: CommandContext) => { context.ui.addItem({ type: MessageType.TOOL_STATS, } as HistoryItemToolStats); }, }, ], }; ================================================ FILE: packages/cli/src/ui/commands/terminalSetupCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { terminalSetupCommand } from './terminalSetupCommand.js'; import * as terminalSetupModule from '../utils/terminalSetup.js'; import type { CommandContext } from './types.js'; vi.mock('../utils/terminalSetup.js'); describe('terminalSetupCommand', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should have correct metadata', () => { expect(terminalSetupCommand.name).toBe('terminal-setup'); expect(terminalSetupCommand.description).toContain('multiline input'); expect(terminalSetupCommand.kind).toBe('built-in'); }); it('should return success message when terminal setup succeeds', async () => { vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ success: true, message: 'Terminal configured successfully', }); const result = await terminalSetupCommand.action!({} as CommandContext, ''); expect(result).toEqual({ type: 'message', content: 'Terminal configured successfully', messageType: 'info', }); }); it('should append restart message when terminal setup requires restart', async () => { vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ success: true, message: 'Terminal configured successfully', requiresRestart: true, }); const result = await terminalSetupCommand.action!({} as CommandContext, ''); expect(result).toEqual({ type: 'message', content: 'Terminal configured successfully\n\nPlease restart your terminal for the changes to take effect.', messageType: 'info', }); }); it('should return error message when terminal setup fails', async () => { vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({ success: false, message: 'Failed to detect terminal', }); const result = await terminalSetupCommand.action!({} as CommandContext, ''); expect(result).toEqual({ type: 'message', content: 'Failed to detect terminal', messageType: 'error', }); }); it('should handle exceptions from terminal setup', async () => { vi.spyOn(terminalSetupModule, 'terminalSetup').mockRejectedValue( new Error('Unexpected error'), ); const result = await terminalSetupCommand.action!({} as CommandContext, ''); expect(result).toEqual({ type: 'message', content: 'Failed to configure terminal: Error: Unexpected error', messageType: 'error', }); }); }); ================================================ FILE: packages/cli/src/ui/commands/terminalSetupCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; import { terminalSetup } from '../utils/terminalSetup.js'; import { type MessageActionReturn } from '@google/gemini-cli-core'; /** * Command to configure terminal keybindings for multiline input support. * * This command automatically detects and configures VS Code, Cursor, and Windsurf * to support Shift+Enter and Ctrl+Enter for multiline input. */ export const terminalSetupCommand: SlashCommand = { name: 'terminal-setup', description: 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (): Promise => { try { const result = await terminalSetup(); let content = result.message; if (result.requiresRestart) { content += '\n\nPlease restart your terminal for the changes to take effect.'; } return { type: 'message', content, messageType: result.success ? 'info' : 'error', }; } catch (error) { return { type: 'message', content: `Failed to configure terminal: ${error}`, messageType: 'error', }; } }, }; ================================================ FILE: packages/cli/src/ui/commands/themeCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach } from 'vitest'; import { themeCommand } from './themeCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; describe('themeCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext(); }); it('should return a dialog action to open the theme dialog', () => { // Ensure the command has an action to test. if (!themeCommand.action) { throw new Error('The theme command must have an action.'); } const result = themeCommand.action(mockContext, ''); // Assert that the action returns the correct object to trigger the theme dialog. expect(result).toEqual({ type: 'dialog', dialog: 'theme', }); }); it('should have the correct name and description', () => { expect(themeCommand.name).toBe('theme'); expect(themeCommand.description).toBe('Change the theme'); }); }); ================================================ FILE: packages/cli/src/ui/commands/themeCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type OpenDialogActionReturn, type SlashCommand, } from './types.js'; export const themeCommand: SlashCommand = { name: 'theme', description: 'Change the theme', kind: CommandKind.BUILT_IN, autoExecute: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'theme', }), }; ================================================ FILE: packages/cli/src/ui/commands/toolsCommand.test.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, type vi } from 'vitest'; import { toolsCommand } from './toolsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import type { ToolBuilder, ToolResult } from '@google/gemini-cli-core'; // Mock tools for testing const mockTools = [ { name: 'file-reader', displayName: 'File Reader', description: 'Reads files from the local system.', schema: {}, }, { name: 'code-editor', displayName: 'Code Editor', description: 'Edits code files.', schema: {}, }, ] as unknown as Array>; describe('toolsCommand', () => { it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: undefined, }, }, }); if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: 'Could not retrieve tool registry.', }); }); it('should display "No tools available" when none are found', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => [] as Array>, }, }, }, }); if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.TOOLS_LIST, tools: [], showDescriptions: false, }); }); it('should list tools without descriptions by default (no args)', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.type).toBe(MessageType.TOOLS_LIST); expect(message.showDescriptions).toBe(false); expect(message.tools).toHaveLength(2); expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[1].displayName).toBe('Code Editor'); }); it('should list tools without descriptions when "list" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, 'list'); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.type).toBe(MessageType.TOOLS_LIST); expect(message.showDescriptions).toBe(false); expect(message.tools).toHaveLength(2); expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[1].displayName).toBe('Code Editor'); }); it('should list tools with descriptions when "desc" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, 'desc'); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.type).toBe(MessageType.TOOLS_LIST); expect(message.showDescriptions).toBe(true); expect(message.tools).toHaveLength(2); expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[0].description).toBe( 'Reads files from the local system.', ); expect(message.tools[1].displayName).toBe('Code Editor'); expect(message.tools[1].description).toBe('Edits code files.'); }); it('should have "list" and "desc" subcommands', () => { expect(toolsCommand.subCommands).toBeDefined(); const names = toolsCommand.subCommands?.map((s) => s.name); expect(names).toContain('list'); expect(names).toContain('desc'); expect(names).not.toContain('descriptions'); }); it('subcommand "list" should display tools without descriptions', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); const listCmd = toolsCommand.subCommands?.find((s) => s.name === 'list'); if (!listCmd?.action) throw new Error('Action not defined'); await listCmd.action(mockContext, ''); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.showDescriptions).toBe(false); expect(message.tools).toHaveLength(2); expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[1].displayName).toBe('Code Editor'); }); it('subcommand "desc" should display tools with descriptions', async () => { const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); const descCmd = toolsCommand.subCommands?.find((s) => s.name === 'desc'); if (!descCmd?.action) throw new Error('Action not defined'); await descCmd.action(mockContext, ''); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.showDescriptions).toBe(true); expect(message.tools).toHaveLength(2); expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[0].description).toBe( 'Reads files from the local system.', ); expect(message.tools[1].displayName).toBe('Code Editor'); expect(message.tools[1].description).toBe('Edits code files.'); }); it('should expose a desc subcommand for TUI discoverability', async () => { const descSubCommand = toolsCommand.subCommands?.find( (cmd) => cmd.name === 'desc', ); expect(descSubCommand).toBeDefined(); expect(descSubCommand?.description).toContain('descriptions'); const mockContext = createMockCommandContext({ services: { agentContext: { toolRegistry: { getAllTools: () => mockTools }, }, }, }); if (!descSubCommand?.action) throw new Error('Action not defined'); await descSubCommand.action(mockContext, ''); const [message] = (mockContext.ui.addItem as ReturnType).mock .calls[0]; expect(message.type).toBe(MessageType.TOOLS_LIST); expect(message.showDescriptions).toBe(true); }); }); ================================================ FILE: packages/cli/src/ui/commands/toolsCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; import { MessageType, type HistoryItemToolsList } from '../types.js'; async function listTools( context: CommandContext, showDescriptions: boolean, ): Promise { const toolRegistry = context.services.agentContext?.toolRegistry; if (!toolRegistry) { context.ui.addItem({ type: MessageType.ERROR, text: 'Could not retrieve tool registry.', }); return; } const tools = toolRegistry.getAllTools(); // Filter out MCP tools by checking for the absence of a serverName property const geminiTools = tools.filter((tool) => !('serverName' in tool)); const toolsListItem: HistoryItemToolsList = { type: MessageType.TOOLS_LIST, tools: geminiTools.map((tool) => ({ name: tool.name, displayName: tool.displayName, description: tool.description, })), showDescriptions, }; context.ui.addItem(toolsListItem); } const listSubCommand: SlashCommand = { name: 'list', description: 'List available Gemini CLI tools.', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext): Promise => listTools(context, false), }; const descSubCommand: SlashCommand = { name: 'desc', altNames: ['descriptions'], description: 'List available Gemini CLI tools with descriptions.', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext): Promise => listTools(context, true), }; export const toolsCommand: SlashCommand = { name: 'tools', description: 'List available Gemini CLI tools. Use /tools desc to include descriptions.', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [listSubCommand, descSubCommand], action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); // Keep backward compatibility for typed arguments while exposing subcommands in TUI. const useShowDescriptions = subCommand === 'desc' || subCommand === 'descriptions'; await listTools(context, useShowDescriptions); }, }; ================================================ FILE: packages/cli/src/ui/commands/types.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { ReactNode } from 'react'; import type { HistoryItemWithoutId, HistoryItem, ConfirmationRequest, } from '../types.js'; import type { GitService, Logger, CommandActionReturn, AgentDefinition, AgentLoopContext, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { ExtensionUpdateAction, ExtensionUpdateStatus, } from '../state/extensions.js'; // Grouped dependencies for clarity and easier mocking export interface CommandContext { // Invocation properties for when commands are called. invocation?: { /** The raw, untrimmed input string from the user. */ raw: string; /** The primary name of the command that was matched. */ name: string; /** The arguments string that follows the command name. */ args: string; }; // Core services and configuration services: { // TODO(abhipatel12): Ensure that config is never null. agentContext: AgentLoopContext | null; settings: LoadedSettings; git: GitService | undefined; logger: Logger; }; // UI state and history management ui: { /** Adds a new item to the history display. */ addItem: UseHistoryManagerReturn['addItem']; /** Clears all history items and the console screen. */ clear: () => void; /** * Sets the transient debug message displayed in the application footer in debug mode. */ setDebugMessage: (message: string) => void; /** The currently pending history item, if any. */ pendingItem: HistoryItemWithoutId | null; /** * Sets a pending item in the history, which is useful for indicating * that a long-running operation is in progress. * * @param item The history item to display as pending, or `null` to clear. */ setPendingItem: (item: HistoryItemWithoutId | null) => void; /** * Loads a new set of history items, replacing the current history. * * @param history The array of history items to load. * @param postLoadInput Optional text to set in the input buffer after loading history. */ loadHistory: (history: HistoryItem[], postLoadInput?: string) => void; /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleDebugProfiler: () => void; toggleVimEnabled: () => Promise; reloadCommands: () => void; openAgentConfigDialog: ( name: string, displayName: string, definition: AgentDefinition, ) => void; extensionsUpdateState: Map; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; /** * Sets a confirmation request to be displayed to the user. * * @param value The confirmation request details. */ setConfirmationRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; toggleBackgroundShell: () => void; toggleShortcutsHelp: () => void; }; // Session-specific data session: { stats: SessionStatsState; /** A transient list of shell commands the user has approved for this session. */ sessionShellAllowlist: Set; }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; } /** The return type for a command action that results in the app quitting. */ export interface QuitActionReturn { type: 'quit'; messages: HistoryItem[]; } /** * The return type for a command action that needs to open a dialog. */ export interface OpenDialogActionReturn { type: 'dialog'; props?: Record; dialog: | 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings' | 'sessionBrowser' | 'model' | 'agentConfig' | 'permissions'; } /** * The return type for a command action that needs to pause and request * confirmation for a set of shell commands before proceeding. */ export interface ConfirmShellCommandsActionReturn { type: 'confirm_shell_commands'; /** The list of shell commands that require user confirmation. */ commandsToConfirm: string[]; /** The original invocation context to be re-run after confirmation. */ originalInvocation: { raw: string; }; } export interface ConfirmActionReturn { type: 'confirm_action'; /** The React node to display as the confirmation prompt. */ prompt: ReactNode; /** The original invocation context to be re-run after confirmation. */ originalInvocation: { raw: string; }; } export interface OpenCustomDialogActionReturn { type: 'custom_dialog'; component: ReactNode; } /** * The return type for a command action that specifically handles logout logic, * signaling the application to explicitly transition to an unauthenticated state. */ export interface LogoutActionReturn { type: 'logout'; } export type SlashCommandActionReturn = | CommandActionReturn | QuitActionReturn | OpenDialogActionReturn | ConfirmShellCommandsActionReturn | ConfirmActionReturn | OpenCustomDialogActionReturn | LogoutActionReturn; export enum CommandKind { BUILT_IN = 'built-in', USER_FILE = 'user-file', WORKSPACE_FILE = 'workspace-file', EXTENSION_FILE = 'extension-file', MCP_PROMPT = 'mcp-prompt', AGENT = 'agent', SKILL = 'skill', } // The standardized contract for any command in the system. export interface SlashCommand { name: string; altNames?: string[]; description: string; hidden?: boolean; /** * Optional grouping label for slash completion UI sections. * Commands with the same label are rendered under one separator. */ suggestionGroup?: string; kind: CommandKind; /** * Controls whether the command auto-executes when selected with Enter. * * If true, pressing Enter on the suggestion will execute the command immediately. * If false or undefined, pressing Enter will autocomplete the command into the prompt window. */ autoExecute?: boolean; /** * Whether this command can be safely executed while the agent is busy (e.g. streaming a response). */ isSafeConcurrent?: boolean; // Optional metadata for extension commands extensionName?: string; extensionId?: string; // Optional metadata for MCP commands mcpServerName?: string; // The action to run. Optional for parent commands that only group sub-commands. action?: ( context: CommandContext, args: string, // TODO: Remove args. CommandContext now contains the complete invocation. ) => | void | SlashCommandActionReturn | Promise; // Provides argument completion (e.g., completing a tag for `/resume resume `). completion?: ( context: CommandContext, partialArg: string, ) => Promise | string[]; /** * Whether to show the loading indicator while fetching completions. * Defaults to true. Set to false for fast completions to avoid flicker. */ showCompletionLoading?: boolean; subCommands?: SlashCommand[]; } ================================================ FILE: packages/cli/src/ui/commands/upgradeCommand.test.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { upgradeCommand } from './upgradeCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { AuthType, openBrowserSecurely, shouldLaunchBrowser, UPGRADE_URL_PAGE, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, openBrowserSecurely: vi.fn(), shouldLaunchBrowser: vi.fn().mockReturnValue(true), UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist', }; }); describe('upgradeCommand', () => { let mockContext: CommandContext; beforeEach(() => { vi.clearAllMocks(); mockContext = createMockCommandContext({ services: { agentContext: { config: { getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: AuthType.LOGIN_WITH_GOOGLE, }), getUserTierName: vi.fn().mockReturnValue(undefined), }, }, }, } as unknown as CommandContext); }); it('should have the correct name and description', () => { expect(upgradeCommand.name).toBe('upgrade'); expect(upgradeCommand.description).toBe( 'Upgrade your Gemini Code Assist tier for higher limits', ); }); it('should call openBrowserSecurely with UPGRADE_URL_PAGE when logged in with Google', async () => { if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); } await upgradeCommand.action(mockContext, ''); expect(openBrowserSecurely).toHaveBeenCalledWith(UPGRADE_URL_PAGE); }); it('should return an error message when NOT logged in with Google', async () => { vi.mocked( mockContext.services.agentContext!.config.getContentGeneratorConfig, ).mockReturnValue({ authType: AuthType.USE_GEMINI, }); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); } const result = await upgradeCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'The /upgrade command is only available when logged in with Google.', }); expect(openBrowserSecurely).not.toHaveBeenCalled(); }); it('should return an error message if openBrowserSecurely fails', async () => { vi.mocked(openBrowserSecurely).mockRejectedValue( new Error('Failed to open'), ); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); } const result = await upgradeCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Failed to open upgrade page: Failed to open', }); }); it('should return URL message when shouldLaunchBrowser returns false', async () => { vi.mocked(shouldLaunchBrowser).mockReturnValue(false); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); } const result = await upgradeCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, }); expect(openBrowserSecurely).not.toHaveBeenCalled(); }); it('should return info message for ultra tiers', async () => { vi.mocked( mockContext.services.agentContext!.config.getUserTierName, ).mockReturnValue('Advanced Ultra'); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); } const result = await upgradeCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'You are already on the highest tier: Advanced Ultra.', }); expect(openBrowserSecurely).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/ui/commands/upgradeCommand.ts ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { AuthType, openBrowserSecurely, shouldLaunchBrowser, UPGRADE_URL_PAGE, } from '@google/gemini-cli-core'; import { isUltraTier } from '../../utils/tierUtils.js'; import { CommandKind, type SlashCommand } from './types.js'; /** * Command to open the upgrade page for Gemini Code Assist. * Only intended to be shown/available when the user is logged in with Google. */ export const upgradeCommand: SlashCommand = { name: 'upgrade', kind: CommandKind.BUILT_IN, description: 'Upgrade your Gemini Code Assist tier for higher limits', autoExecute: true, action: async (context) => { const config = context.services.agentContext?.config; const authType = config?.getContentGeneratorConfig()?.authType; if (authType !== AuthType.LOGIN_WITH_GOOGLE) { // This command should ideally be hidden if not logged in with Google, // but we add a safety check here just in case. return { type: 'message', messageType: 'error', content: 'The /upgrade command is only available when logged in with Google.', }; } const tierName = config?.getUserTierName(); if (isUltraTier(tierName)) { return { type: 'message', messageType: 'info', content: `You are already on the highest tier: ${tierName}.`, }; } if (!shouldLaunchBrowser()) { return { type: 'message', messageType: 'info', content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, }; } try { await openBrowserSecurely(UPGRADE_URL_PAGE); } catch (error) { return { type: 'message', messageType: 'error', content: `Failed to open upgrade page: ${error instanceof Error ? error.message : String(error)}`, }; } return undefined; }, }; ================================================ FILE: packages/cli/src/ui/commands/vimCommand.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { CommandKind, type SlashCommand } from './types.js'; export const vimCommand: SlashCommand = { name: 'vim', description: 'Toggle vim mode on/off', kind: CommandKind.BUILT_IN, autoExecute: true, isSafeConcurrent: true, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); const message = newVimState ? 'Entered Vim mode. Run /vim again to exit.' : 'Exited Vim mode.'; return { type: 'message', messageType: 'info', content: message, }; }, }; ================================================ FILE: packages/cli/src/ui/components/AboutBox.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { AboutBox } from './AboutBox.js'; import { describe, it, expect, vi } from 'vitest'; // Mock GIT_COMMIT_INFO vi.mock('../../generated/git-commit.js', () => ({ GIT_COMMIT_INFO: 'mock-commit-hash', })); describe('AboutBox', () => { const defaultProps = { cliVersion: '1.0.0', osVersion: 'macOS', sandboxEnv: 'default', modelVersion: 'gemini-pro', selectedAuthType: 'oauth', gcpProject: '', ideClient: '', }; it('renders with required props', async () => { const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); expect(output).toContain('mock-commit-hash'); expect(output).toContain('gemini-pro'); expect(output).toContain('default'); expect(output).toContain('macOS'); expect(output).toContain('Signed in with Google'); unmount(); }); it.each([ ['gcpProject', 'my-project', 'GCP Project'], ['ideClient', 'vscode', 'IDE Client'], ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); unmount(); }); it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Signed in with Google (test@example.com)'); unmount(); }); it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain('api-key'); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/AboutBox.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { getDisplayString } from '@google/gemini-cli-core'; interface AboutBoxProps { cliVersion: string; osVersion: string; sandboxEnv: string; modelVersion: string; selectedAuthType: string; gcpProject: string; ideClient: string; userEmail?: string; tier?: string; } export const AboutBox: React.FC = ({ cliVersion, osVersion, sandboxEnv, modelVersion, selectedAuthType, gcpProject, ideClient, userEmail, tier, }) => { const settings = useSettings(); const showUserIdentity = settings.merged.ui.showUserIdentity; return ( About Gemini CLI CLI Version {cliVersion} {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( Git Commit {GIT_COMMIT_INFO} )} Model {getDisplayString(modelVersion)} Sandbox {sandboxEnv} OS {osVersion} {showUserIdentity && ( Auth Method {selectedAuthType.startsWith('oauth') ? userEmail ? `Signed in with Google (${userEmail})` : 'Signed in with Google' : selectedAuthType} )} {showUserIdentity && tier && ( Tier {tier} )} {gcpProject && ( GCP Project {gcpProject} )} {ideClient && ( IDE Client {ideClient} )} ); }; ================================================ FILE: packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { act } from 'react'; import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; const handleRestartMock = vi.fn(); describe('AdminSettingsChangedDialog', () => { afterEach(() => { vi.restoreAllMocks(); }); it('renders correctly', async () => { const { lastFrame, waitUntilReady } = await renderWithProviders( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { handleRestart: handleRestartMock, }, }, ); await waitUntilReady(); act(() => { stdin.write('r'); }); expect(handleRestartMock).toHaveBeenCalled(); }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { handleRestart: handleRestartMock, }, }, ); await waitUntilReady(); act(() => { stdin.write(key); }); expect(handleRestartMock).toHaveBeenCalled(); }); }); ================================================ FILE: packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { Command } from '../key/keyMatchers.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export const AdminSettingsChangedDialog = () => { const keyMatchers = useKeyMatchers(); const { handleRestart } = useUIActions(); useKeypress( (key) => { if (keyMatchers[Command.RESTART_APP](key)) { handleRestart(); return true; } return false; }, { isActive: true }, ); const message = 'Admin settings have changed. Please restart the session to apply new settings.'; return ( {message} Press 'r' to restart, or 'Ctrl+C' twice to exit. ); }; ================================================ FILE: packages/cli/src/ui/components/AgentConfigDialog.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { AgentConfigDialog } from './AgentConfigDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { AgentDefinition } from '@google/gemini-cli-core'; enum TerminalKeys { ENTER = '\u000D', TAB = '\t', UP_ARROW = '\u001B[A', DOWN_ARROW = '\u001B[B', ESCAPE = '\u001B', } const createMockSettings = ( userSettings = {}, workspaceSettings = {}, ): LoadedSettings => { const settings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {}, agents: {} }, originalSettings: { ui: { customThemes: {} }, mcpServers: {}, agents: {}, }, path: '/system/settings.json', }, { settings: {}, originalSettings: {}, path: '/system/system-defaults.json', }, { settings: { ui: { customThemes: {} }, mcpServers: {}, agents: { overrides: {} }, ...userSettings, }, originalSettings: { ui: { customThemes: {} }, mcpServers: {}, agents: { overrides: {} }, ...userSettings, }, path: '/user/settings.json', }, { settings: { ui: { customThemes: {} }, mcpServers: {}, agents: { overrides: {} }, ...workspaceSettings, }, originalSettings: { ui: { customThemes: {} }, mcpServers: {}, agents: { overrides: {} }, ...workspaceSettings, }, path: '/workspace/settings.json', }, true, [], ); // Mock setValue settings.setValue = vi.fn(); return settings; }; const createMockAgentDefinition = ( overrides: Partial = {}, ): AgentDefinition => ({ name: 'test-agent', displayName: 'Test Agent', description: 'A test agent for testing', kind: 'local', modelConfig: { model: 'inherit', generateContentConfig: { temperature: 1.0, }, }, runConfig: { maxTimeMinutes: 5, maxTurns: 10, }, experimental: false, ...overrides, }) as AgentDefinition; describe('AgentConfigDialog', () => { let mockOnClose: ReturnType; let mockOnSave: ReturnType; beforeEach(() => { vi.clearAllMocks(); mockOnClose = vi.fn(); mockOnSave = vi.fn(); }); const renderDialog = async ( settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { const result = await renderWithProviders( , { settings, uiState: { mainAreaWidth: 100 } }, ); await result.waitUntilReady(); return result; }; describe('rendering', () => { it('should render the dialog with title', async () => { const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Configure: Test Agent'); unmount(); }); it('should render all configuration fields', async () => { const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings); const frame = lastFrame(); expect(frame).toContain('Enabled'); expect(frame).toContain('Model'); expect(frame).toContain('Temperature'); expect(frame).toContain('Top P'); expect(frame).toContain('Top K'); expect(frame).toContain('Max Output Tokens'); expect(frame).toContain('Max Time (minutes)'); expect(frame).toContain('Max Turns'); unmount(); }); it('should render scope selector', async () => { const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Apply To'); expect(lastFrame()).toContain('User Settings'); expect(lastFrame()).toContain('Workspace Settings'); unmount(); }); it('should render help text', async () => { const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Use Enter to select'); expect(lastFrame()).toContain('Tab to change focus'); expect(lastFrame()).toContain('Esc to close'); unmount(); }); }); describe('keyboard navigation', () => { it('should close dialog on Escape', async () => { const settings = createMockSettings(); const { stdin, waitUntilReady, unmount } = await renderDialog(settings); await act(async () => { stdin.write(TerminalKeys.ESCAPE); }); // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act await act(async () => { await waitUntilReady(); }); await waitFor(() => { expect(mockOnClose).toHaveBeenCalled(); }); unmount(); }); it('should navigate down with arrow key', async () => { const settings = createMockSettings(); const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog(settings); // Initially first item (Enabled) should be active expect(lastFrame()).toContain('●'); // Press down arrow await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await waitFor(() => { // Model field should now be highlighted expect(lastFrame()).toContain('Model'); }); unmount(); }); it('should switch focus with Tab', async () => { const settings = createMockSettings(); const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog(settings); // Initially settings section is focused expect(lastFrame()).toContain('> Configure: Test Agent'); // Press Tab to switch to scope selector await act(async () => { stdin.write(TerminalKeys.TAB); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('> Apply To'); }); unmount(); }); }); describe('boolean toggle', () => { it('should toggle enabled field on Enter', async () => { const settings = createMockSettings(); const { stdin, waitUntilReady, unmount } = await renderDialog(settings); // Press Enter to toggle the first field (Enabled) await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(settings.setValue).toHaveBeenCalledWith( SettingScope.User, 'agents.overrides.test-agent.enabled', false, // Toggles from true (default) to false ); expect(mockOnSave).toHaveBeenCalled(); }); unmount(); }); }); describe('default values', () => { it('should show values from agent definition as defaults', async () => { const definition = createMockAgentDefinition({ modelConfig: { model: 'gemini-2.0-flash', generateContentConfig: { temperature: 0.7, }, }, runConfig: { maxTimeMinutes: 10, maxTurns: 20, }, }); const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings, definition); const frame = lastFrame(); expect(frame).toContain('gemini-2.0-flash'); expect(frame).toContain('0.7'); expect(frame).toContain('10'); expect(frame).toContain('20'); unmount(); }); it('should show experimental agents as disabled by default', async () => { const definition = createMockAgentDefinition({ experimental: true, }); const settings = createMockSettings(); const { lastFrame, unmount } = await renderDialog(settings, definition); // Experimental agents default to disabled expect(lastFrame()).toContain('false'); unmount(); }); }); describe('existing overrides', () => { it('should show existing override values with * indicator', async () => { const settings = createMockSettings({ agents: { overrides: { 'test-agent': { enabled: false, modelConfig: { model: 'custom-model', }, }, }, }, }); const { lastFrame, unmount } = await renderDialog(settings); const frame = lastFrame(); // Should show the overridden values expect(frame).toContain('custom-model'); expect(frame).toContain('false'); unmount(); }); it('should respond to availableTerminalHeight and truncate list', async () => { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) const { lastFrame, unmount } = await renderWithProviders( , { settings, uiState: { mainAreaWidth: 100 } }, ); await waitFor(() => expect(lastFrame()).toContain('Configure: Test Agent'), ); const frame = lastFrame(); // At height 20, it should be heavily truncated and show '▼' expect(frame).toContain('▼'); unmount(); }); }); }); ================================================ FILE: packages/cli/src/ui/components/AgentConfigDialog.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { SettingScope, type LoadableSettingScope, type LoadedSettings, } from '../../config/settings.js'; import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; import { getNestedValue, isRecord } from '../../utils/settingsUtils.js'; /** * Configuration field definition for agent settings */ interface AgentConfigField { key: string; label: string; description: string; type: 'boolean' | 'number' | 'string'; path: string[]; // Path within AgentOverride, e.g., ['modelConfig', 'generateContentConfig', 'temperature'] defaultValue: boolean | number | string | undefined; } /** * Agent configuration fields */ const AGENT_CONFIG_FIELDS: AgentConfigField[] = [ { key: 'enabled', label: 'Enabled', description: 'Enable or disable this agent', type: 'boolean', path: ['enabled'], defaultValue: true, }, { key: 'model', label: 'Model', description: "Model to use (e.g., 'gemini-2.0-flash' or 'inherit')", type: 'string', path: ['modelConfig', 'model'], defaultValue: 'inherit', }, { key: 'temperature', label: 'Temperature', description: 'Sampling temperature (0.0 to 2.0)', type: 'number', path: ['modelConfig', 'generateContentConfig', 'temperature'], defaultValue: undefined, }, { key: 'topP', label: 'Top P', description: 'Nucleus sampling parameter (0.0 to 1.0)', type: 'number', path: ['modelConfig', 'generateContentConfig', 'topP'], defaultValue: undefined, }, { key: 'topK', label: 'Top K', description: 'Top-K sampling parameter', type: 'number', path: ['modelConfig', 'generateContentConfig', 'topK'], defaultValue: undefined, }, { key: 'maxOutputTokens', label: 'Max Output Tokens', description: 'Maximum number of tokens to generate', type: 'number', path: ['modelConfig', 'generateContentConfig', 'maxOutputTokens'], defaultValue: undefined, }, { key: 'maxTimeMinutes', label: 'Max Time (minutes)', description: 'Maximum execution time in minutes', type: 'number', path: ['runConfig', 'maxTimeMinutes'], defaultValue: undefined, }, { key: 'maxTurns', label: 'Max Turns', description: 'Maximum number of conversational turns', type: 'number', path: ['runConfig', 'maxTurns'], defaultValue: undefined, }, ]; interface AgentConfigDialogProps { agentName: string; displayName: string; definition: AgentDefinition; settings: LoadedSettings; onClose: () => void; onSave?: () => void; /** Available terminal height for dynamic windowing */ availableTerminalHeight?: number; } /** * Set a nested value in an object using a path array, creating intermediate objects as needed */ function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { if (!isRecord(obj)) return obj; const result = { ...obj }; let current = result; for (let i = 0; i < path.length - 1; i++) { const key = path[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; } else if (isRecord(current[key])) { current[key] = { ...current[key] }; } const next = current[key]; if (isRecord(next)) { current = next; } else { // Cannot traverse further through non-objects return result; } } const finalKey = path[path.length - 1]; if (value === undefined) { delete current[finalKey]; } else { current[finalKey] = value; } return result; } /** * Get the effective default value for a field from the agent definition */ function getFieldDefaultFromDefinition( field: AgentConfigField, definition: AgentDefinition, ): unknown { if (definition.kind !== 'local') return field.defaultValue; if (field.key === 'enabled') { return !definition.experimental; // Experimental agents default to disabled } if (field.key === 'model') { return definition.modelConfig?.model ?? 'inherit'; } if (field.key === 'temperature') { return definition.modelConfig?.generateContentConfig?.temperature; } if (field.key === 'topP') { return definition.modelConfig?.generateContentConfig?.topP; } if (field.key === 'topK') { return definition.modelConfig?.generateContentConfig?.topK; } if (field.key === 'maxOutputTokens') { return definition.modelConfig?.generateContentConfig?.maxOutputTokens; } if (field.key === 'maxTimeMinutes') { return definition.runConfig?.maxTimeMinutes; } if (field.key === 'maxTurns') { return definition.runConfig?.maxTurns; } return field.defaultValue; } export function AgentConfigDialog({ agentName, displayName, definition, settings, onClose, onSave, availableTerminalHeight, }: AgentConfigDialogProps): React.JSX.Element { // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); // Pending override state for the selected scope const [pendingOverride, setPendingOverride] = useState(() => { const scopeSettings = settings.forScope(selectedScope).settings; const existingOverride = scopeSettings.agents?.overrides?.[agentName]; return existingOverride ? structuredClone(existingOverride) : {}; }); // Track which fields have been modified const [modifiedFields, setModifiedFields] = useState>(new Set()); // Update pending override when scope changes useEffect(() => { const scopeSettings = settings.forScope(selectedScope).settings; const existingOverride = scopeSettings.agents?.overrides?.[agentName]; setPendingOverride( existingOverride ? structuredClone(existingOverride) : {}, ); setModifiedFields(new Set()); }, [selectedScope, settings, agentName]); /** * Save a specific field value to settings */ const saveFieldValue = useCallback( (fieldKey: string, path: string[], value: unknown) => { // Guard against prototype pollution if (['__proto__', 'constructor', 'prototype'].includes(agentName)) { return; } // Build the full settings path for agent override // e.g., agents.overrides..modelConfig.generateContentConfig.temperature const settingsPath = ['agents', 'overrides', agentName, ...path].join( '.', ); settings.setValue(selectedScope, settingsPath, value); onSave?.(); }, [settings, selectedScope, agentName, onSave], ); // Calculate max label width const maxLabelWidth = useMemo(() => { let max = 0; for (const field of AGENT_CONFIG_FIELDS) { const lWidth = getCachedStringWidth(field.label); const dWidth = getCachedStringWidth(field.description); max = Math.max(max, lWidth, dWidth); } return max; }, []); // Generate items for BaseSettingsDialog const items: SettingsDialogItem[] = useMemo( () => AGENT_CONFIG_FIELDS.map((field) => { const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; let displayValue: string; if (field.type === 'boolean') { displayValue = effectiveValue ? 'true' : 'false'; } else if (effectiveValue !== undefined && effectiveValue !== null) { displayValue = String(effectiveValue); } else { displayValue = '(default)'; } // Add * if modified const isModified = modifiedFields.has(field.key) || currentValue !== undefined; if (isModified && currentValue !== undefined) { displayValue += '*'; } // Get raw value for edit mode const rawValue = currentValue !== undefined ? currentValue : effectiveValue; return { key: field.key, label: field.label, description: field.description, type: field.type, displayValue, isGreyedOut: currentValue === undefined, scopeMessage: undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawValue: rawValue as string | number | boolean | undefined, }; }), [pendingOverride, definition, modifiedFields], ); const maxItemsToShow = 8; // Handle scope changes const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); }, []); // Handle toggle for boolean fields const handleItemToggle = useCallback( (key: string, _item: SettingsDialogItem) => { const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field || field.type !== 'boolean') return; const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; const newValue = !effectiveValue; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( pendingOverride, field.path, newValue, ) as AgentOverride; setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); // Save the field value to settings saveFieldValue(field.key, field.path, newValue); }, [pendingOverride, definition, saveFieldValue], ); // Handle edit commit for string/number fields const handleEditCommit = useCallback( (key: string, newValue: string, _item: SettingsDialogItem) => { const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field) return; let parsed: string | number | undefined; if (field.type === 'number') { if (newValue.trim() === '') { // Empty means clear the override parsed = undefined; } else { const numParsed = Number(newValue.trim()); if (Number.isNaN(numParsed)) { // Invalid number; don't save return; } parsed = numParsed; } } else { // For strings, empty means clear the override parsed = newValue.trim() === '' ? undefined : newValue; } // Update pending override locally // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( pendingOverride, field.path, parsed, ) as AgentOverride; setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); // Save the field value to settings saveFieldValue(field.key, field.path, parsed); }, [pendingOverride, saveFieldValue], ); // Handle clear/reset - reset to default value (removes override) const handleItemClear = useCallback( (key: string, _item: SettingsDialogItem) => { const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field) return; // Remove the override (set to undefined) // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( pendingOverride, field.path, undefined, ) as AgentOverride; setPendingOverride(newOverride); setModifiedFields((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); // Save as undefined to remove the override saveFieldValue(field.key, field.path, undefined); }, [pendingOverride, saveFieldValue], ); return ( 0 ? { content: ( Changes saved automatically. ), height: 1, } : undefined } /> ); } ================================================ FILE: packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders, persistentStateMock, } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { Text } from 'ink'; import { CoreToolCallStatus } from '@google/gemini-cli-core'; vi.mock('../utils/terminalSetup.js', () => ({ getTerminalProgram: () => null, })); vi.mock('../contexts/AppContext.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useAppContext: () => ({ version: '0.10.0', }), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getMCPServerStatus: vi.fn(), }; }); vi.mock('../GeminiRespondingSpinner.js', () => ({ GeminiRespondingSpinner: () => Spinner, })); const mockHistory: HistoryItem[] = [ { id: 1, type: 'tool_group', tools: [ { callId: 'call1', name: 'tool1', description: 'Description for tool 1', status: CoreToolCallStatus.Success, resultDisplay: undefined, confirmationDetails: undefined, }, ], }, { id: 2, type: 'tool_group', tools: [ { callId: 'call2', name: 'tool2', description: 'Description for tool 2', status: CoreToolCallStatus.Success, resultDisplay: undefined, confirmationDetails: undefined, }, ], }, ]; const mockPendingHistoryItems: HistoryItemWithoutId[] = [ { type: 'tool_group', tools: [ { callId: 'call3', name: 'tool3', description: 'Description for tool 3', status: CoreToolCallStatus.Scheduled, resultDisplay: undefined, confirmationDetails: undefined, }, ], }, ]; describe('AlternateBufferQuittingDisplay', () => { beforeEach(() => { vi.clearAllMocks(); }); const baseUIState = { terminalWidth: 80, mainAreaWidth: 80, slashCommands: [], activePtyId: undefined, embeddedShellFocused: false, renderMarkdown: false, bannerData: { defaultText: '', warningText: '', }, }; it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history: mockHistory, pendingHistoryItems: mockPendingHistoryItems, }, }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); unmount(); }); it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history: [], pendingHistoryItems: [], }, }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); unmount(); }); it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history: mockHistory, pendingHistoryItems: [], }, }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); unmount(); }); it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history: [], pendingHistoryItems: mockPendingHistoryItems, }, }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); unmount(); }); it('renders with a tool awaiting confirmation', async () => { persistentStateMock.setData({ tipsShown: 0 }); const pendingHistoryItems: HistoryItemWithoutId[] = [ { type: 'tool_group', tools: [ { callId: 'call4', name: 'confirming_tool', description: 'Confirming tool description', status: CoreToolCallStatus.AwaitingApproval, resultDisplay: undefined, confirmationDetails: { type: 'info', title: 'Confirm Tool', prompt: 'Confirm this action?', }, }, ], }, ]; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history: [], pendingHistoryItems, }, }, ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); expect(output).toContain('Confirming tool description'); expect(output).toMatchSnapshot('with_confirming_tool'); unmount(); }); it('renders with user and gemini messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); const history: HistoryItem[] = [ { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { ...baseUIState, history, pendingHistoryItems: [], }, }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { AppHeader } from './AppHeader.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { QuittingDisplay } from './QuittingDisplay.js'; import { useAppContext } from '../contexts/AppContext.js'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; import { theme } from '../semantic-colors.js'; export const AlternateBufferQuittingDisplay = () => { const { version } = useAppContext(); const uiState = useUIState(); const confirmingTool = useConfirmingTool(); const showPromptedTool = confirmingTool !== null; // We render the entire chat history and header here to ensure that the // conversation history is visible to the user after the app quits and the // user exits alternate buffer mode. // Our version of Ink is clever and will render a final frame outside of // the alternate buffer on app exit. return ( {uiState.history.map((h) => ( ))} {uiState.pendingHistoryItems.map((item, i) => ( ))} {showPromptedTool && ( Action Required (was prompted): )} ); }; ================================================ FILE: packages/cli/src/ui/components/AnsiOutput.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { AnsiOutputText } from './AnsiOutput.js'; import type { AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; // Helper to create a valid AnsiToken with default values const createAnsiToken = (overrides: Partial): AnsiToken => ({ text: '', bold: false, italic: false, underline: false, dim: false, inverse: false, fg: '#ffffff', bg: '#000000', ...overrides, }); describe('', () => { it('renders a simple AnsiOutput object correctly', async () => { const data: AnsiOutput = [ [ createAnsiToken({ text: 'Hello, ' }), createAnsiToken({ text: 'world!' }), ], ]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); // Note: ink-testing-library doesn't render styles, so we can only check the text. // We are testing that it renders without crashing. it.each([ { style: { bold: true }, text: 'Bold' }, { style: { italic: true }, text: 'Italic' }, { style: { underline: true }, text: 'Underline' }, { style: { dim: true }, text: 'Dim' }, { style: { inverse: true }, text: 'Inverse' }, ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); it.each([ { color: { fg: '#ff0000' }, text: 'Red FG' }, { color: { bg: '#0000ff' }, text: 'Blue BG' }, { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); it('handles empty lines and empty tokens', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'First line' })], [], [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output.split('\n'); expect(lines[0].trim()).toBe('First line'); expect(lines[1].trim()).toBe(''); expect(lines[2].trim()).toBe('Third line'); unmount(); }); it('respects the availableTerminalHeight prop and slices the lines correctly', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); unmount(); }); it('respects the maxLines prop and slices the lines correctly', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); unmount(); }); it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); unmount(); }); it('renders a large AnsiOutput object without crashing', async () => { const largeData: AnsiOutput = []; for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/AnsiOutput.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; const DEFAULT_HEIGHT = 24; interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; width: number; maxLines?: number; disableTruncation?: boolean; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, width, maxLines, disableTruncation, }) => { const availableHeightLimit = availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight : undefined; const numLinesRetained = availableHeightLimit !== undefined && maxLines !== undefined ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); const lastLines = disableTruncation ? data : numLinesRetained === 0 ? [] : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( ))} ); }; export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => ( {line.length > 0 ? line.map((token: AnsiToken, tokenIndex: number) => ( {token.text} )) : null} ); ================================================ FILE: packages/cli/src/ui/components/AppHeader.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders, persistentStateMock, } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; import { describe, it, expect, vi } from 'vitest'; import crypto from 'node:crypto'; vi.mock('../utils/terminalSetup.js', () => ({ getTerminalProgram: () => null, })); describe('', () => { it('should render the banner with default text', async () => { const uiState = { history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', }, bannerVisible: true, }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should render the banner with warning text', async () => { const uiState = { history: [], bannerData: { defaultText: 'This is the default banner', warningText: 'There are capacity issues', }, bannerVisible: true, }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should not render the banner when no flags are set', async () => { const uiState = { history: [], bannerData: { defaultText: '', warningText: '', }, }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should not render the default banner if shown count is 5 or more', async () => { const uiState = { history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', }, }; persistentStateMock.setData({ defaultBannerShownCount: { [crypto .createHash('sha256') .update(uiState.bannerData.defaultText) .digest('hex')]: 5, }, }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should increment the version count when default banner is displayed', async () => { const uiState = { history: [], bannerData: { defaultText: 'This is the default banner', warningText: '', }, }; // Set tipsShown to 10 or more to prevent Tips from incrementing its count // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); const { waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', { [crypto .createHash('sha256') .update(uiState.bannerData.defaultText) .digest('hex')]: 1, }, ); unmount(); }); it('should render banner text with unescaped newlines', async () => { const uiState = { history: [], bannerData: { defaultText: 'First line\\nSecond line', warningText: '', }, bannerVisible: true, }; const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); }); it('should render Tips when tipsShown is less than 10', async () => { const uiState = { history: [], bannerData: { defaultText: 'First line\\nSecond line', warningText: '', }, bannerVisible: true, }; persistentStateMock.setData({ tipsShown: 5 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); unmount(); }); it('should NOT render Tips when tipsShown is 10 or more', async () => { const uiState = { bannerData: { defaultText: '', warningText: '', }, }; persistentStateMock.setData({ tipsShown: 10 }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, }, ); await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); }); it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); const uiState = { history: [], bannerData: { defaultText: 'First line\\nSecond line', warningText: '', }, bannerVisible: true, }; // First session const session1 = await renderWithProviders(, { uiState, }); await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); session1.unmount(); // Second session - state is persisted in the fake const session2 = await renderWithProviders( , {}, ); await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/AppHeader.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import { UserIdentity } from './UserIdentity.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; import { useTips } from '../hooks/useTips.js'; import { theme } from '../semantic-colors.js'; import { ThemedGradient } from './ThemedGradient.js'; import { CliSpinner } from './CliSpinner.js'; import { isAppleTerminal } from '@google/gemini-cli-core'; interface AppHeaderProps { version: string; showDetails?: boolean; } const DEFAULT_ICON = `▝▜▄ ▝▜▄ ▗▟▀ ▝▀ `; /** * The default Apple Terminal.app adds significant line-height padding between * rows. This breaks Unicode block-drawing characters that rely on vertical * adjacency (like half-blocks). This version is perfectly symmetric vertically, * which makes the padding gaps look like an intentional "scanline" design * rather than a broken image. */ const MAC_TERMINAL_ICON = `▝▜▄ ▝▜▄ ▗▟▀ ▗▟▀ `; export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState(); const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); const showHeader = !( settings.merged.ui.hideBanner || config.getScreenReader() ); const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; if (!showDetails) { return ( {showHeader && ( {ICON} Gemini CLI v{version} )} ); } return ( {showHeader && ( {ICON} {/* Line 1: Gemini CLI vVersion [Updating] */} Gemini CLI v{version} {updateInfo && ( Updating )} {/* Line 2: Blank */} {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} {settings.merged.ui.showUserIdentity !== false && ( )} )} {bannerVisible && bannerText && ( )} {!(settings.merged.ui.hideTips || config.getScreenReader()) && showTips && } ); }; ================================================ FILE: packages/cli/src/ui/components/AppHeaderIcon.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; // We mock the entire module to control the isAppleTerminal export vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isAppleTerminal: vi.fn(), }; }); import { isAppleTerminal } from '@google/gemini-cli-core'; describe('AppHeader Icon Rendering', () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.unstubAllEnvs(); }); it('renders the default icon in standard terminals', async () => { vi.mocked(isAppleTerminal).mockReturnValue(false); const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); }); it('renders the symmetric icon in Apple Terminal', async () => { vi.mocked(isAppleTerminal).mockReturnValue(true); const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); }); }); ================================================ FILE: packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { describe, it, expect } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for PLAN mode', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); ================================================ FILE: packages/cli/src/ui/components/ApprovalModeIndicator.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; import { formatCommand } from '../key/keybindingUtils.js'; import { Command } from '../key/keyBindings.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; allowPlanMode?: boolean; } export const ApprovalModeIndicator: React.FC = ({ approvalMode, allowPlanMode, }) => { let textColor = ''; let textContent = ''; let subText = ''; const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE); const yoloHint = formatCommand(Command.TOGGLE_YOLO); switch (approvalMode) { case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; textContent = 'auto-accept edits'; subText = allowPlanMode ? `${cycleHint} to plan` : `${cycleHint} to manual`; break; case ApprovalMode.PLAN: textColor = theme.status.success; textContent = 'plan'; subText = `${cycleHint} to manual`; break; case ApprovalMode.YOLO: textColor = theme.status.error; textContent = 'YOLO'; subText = yoloHint; break; case ApprovalMode.DEFAULT: default: textColor = theme.text.accent; textContent = ''; subText = `${cycleHint} to accept edits`; break; } return ( {textContent ? textContent : null} {subText ? ( {textContent ? ' ' : ''} {subText} ) : null} ); }; ================================================ FILE: packages/cli/src/ui/components/AsciiArt.ts ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export const shortAsciiLogo = ` █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ `; export const longAsciiLogo = ` ███ █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ ░░░███ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███ ░░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ ░░░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ ███░ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ ███░ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ ███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ ░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ `; export const tinyAsciiLogo = ` ███ █████████ ░░░███ ███░░░░░███ ░░░███ ███ ░░░ ░░░███░███ ███░ ░███ █████ ███░ ░░███ ░░███ ███░ ░░█████████ ░░░ ░░░░░░░░░ `; ================================================ FILE: packages/cli/src/ui/components/AskUserDialog.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; // Helper to write to stdin with proper act() wrapping const writeKey = (stdin: { write: (data: string) => void }, key: string) => { act(() => { stdin.write(key); }); }; describe('AskUserDialog', () => { // Ensure keystrokes appear spaced in time to avoid bufferFastReturn // converting Enter into Shift+Enter during synchronous test execution. let mockTime: number; beforeEach(() => { mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50)); }); afterEach(() => { vi.restoreAllMocks(); }); const authQuestion: Question[] = [ { question: 'Which authentication method should we use?', header: 'Auth', type: QuestionType.CHOICE, options: [ { label: 'OAuth 2.0', description: 'Industry standard, supports SSO' }, { label: 'JWT tokens', description: 'Stateless, good for APIs' }, ], multiSelect: false, }, ]; it('renders question and options', async () => { const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); describe.each([ { name: 'Single Select', questions: authQuestion, actions: (stdin: { write: (data: string) => void }) => { writeKey(stdin, '\r'); }, expectedSubmit: { '0': 'OAuth 2.0' }, }, { name: 'Multi-select', questions: [ { question: 'Which features?', header: 'Features', type: QuestionType.CHOICE, options: [ { label: 'TypeScript', description: '' }, { label: 'ESLint', description: '' }, ], multiSelect: true, }, ] as Question[], actions: (stdin: { write: (data: string) => void }) => { writeKey(stdin, '\r'); // Toggle TS writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\r'); // Toggle ESLint writeKey(stdin, '\x1b[B'); // Down to All of the above writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done }, expectedSubmit: { '0': 'TypeScript, ESLint' }, }, { name: 'All of the above', questions: [ { question: 'Which features?', header: 'Features', type: QuestionType.CHOICE, options: [ { label: 'TypeScript', description: '' }, { label: 'ESLint', description: '' }, ], multiSelect: true, }, ] as Question[], actions: (stdin: { write: (data: string) => void }) => { writeKey(stdin, '\x1b[B'); // Down to ESLint writeKey(stdin, '\x1b[B'); // Down to All of the above writeKey(stdin, '\r'); // Toggle All of the above writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done }, expectedSubmit: { '0': 'TypeScript, ESLint' }, }, { name: 'Text Input', questions: [ { question: 'Name?', header: 'Name', type: QuestionType.TEXT, }, ] as Question[], actions: (stdin: { write: (data: string) => void }) => { for (const char of 'test-app') { writeKey(stdin, char); } writeKey(stdin, '\r'); }, expectedSubmit: { '0': 'test-app' }, }, ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); const { stdin } = await renderWithProviders( , { width: 120 }, ); actions(stdin); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); }); }); }); it('verifies "All of the above" visual state with snapshot', async () => { const questions = [ { question: 'Which features?', header: 'Features', type: QuestionType.CHOICE, options: [ { label: 'TypeScript', description: '' }, { label: 'ESLint', description: '' }, ], multiSelect: true, }, ] as Question[]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Navigate to "All of the above" and toggle it writeKey(stdin, '\x1b[B'); // Down to ESLint writeKey(stdin, '\x1b[B'); // Down to All of the above writeKey(stdin, '\r'); // Toggle All of the above await waitFor(async () => { await waitUntilReady(); // Verify visual state (checkmarks on all options) expect(lastFrame()).toMatchSnapshot(); }); }); it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Move down to custom option writeKey(stdin, '\x1b[B'); writeKey(stdin, '\x1b[B'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Enter a custom value'); }); // Type directly (inline) for (const char of 'API Key') { writeKey(stdin, char); } await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('API Key'); }); // Press Enter to submit the custom value writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); }); }); it('supports multi-line input for "Other" option in choice questions', async () => { const authQuestionWithOther: Question[] = [ { question: 'Which authentication method?', header: 'Auth', type: QuestionType.CHOICE, options: [{ label: 'OAuth 2.0', description: '' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Navigate to "Other" option writeKey(stdin, '\x1b[B'); // Down to "Other" // Type first line for (const char of 'Line 1') { writeKey(stdin, char); } // Insert newline using \ + Enter (handled by bufferBackslashEnter) writeKey(stdin, '\\'); writeKey(stdin, '\r'); // Type second line for (const char of 'Line 2') { writeKey(stdin, char); } await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Line 1'); await waitUntilReady(); expect(lastFrame()).toContain('Line 2'); }); // Press Enter to submit writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' }); }); }); describe.each([ { useAlternateBuffer: true, expectedArrows: false }, { useAlternateBuffer: false, expectedArrows: true }, ])( 'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)', ({ useAlternateBuffer, expectedArrows }) => { it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => { const questions: Question[] = [ { question: 'Choose an option', header: 'Scroll Test', type: QuestionType.CHOICE, options: Array.from({ length: 15 }, (_, i) => ({ label: `Option ${i + 1}`, description: `Description ${i + 1}`, })), multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { config: makeFakeConfig({ useAlternateBuffer }), settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); await waitFor(async () => { if (expectedArrows) { await waitUntilReady(); expect(lastFrame()).toContain('▲'); await waitUntilReady(); expect(lastFrame()).toContain('▼'); } else { await waitUntilReady(); expect(lastFrame()).not.toContain('▲'); await waitUntilReady(); expect(lastFrame()).not.toContain('▼'); } await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); }, ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Type a character without navigating down writeKey(stdin, 'A'); await waitFor(async () => { // Should show the custom input with 'A' // Placeholder is hidden when text is present await waitUntilReady(); expect(lastFrame()).toContain('A'); await waitUntilReady(); expect(lastFrame()).toContain('3. A'); }); // Continue typing writeKey(stdin, 'P'); writeKey(stdin, 'I'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('API'); }); }); it('shows progress header for multiple questions', async () => { const multiQuestions: Question[] = [ { question: 'Which database should we use?', header: 'Database', type: QuestionType.CHOICE, options: [ { label: 'PostgreSQL', description: 'Relational database' }, { label: 'MongoDB', description: 'Document database' }, ], multiSelect: false, }, { question: 'Which ORM do you prefer?', header: 'ORM', type: QuestionType.CHOICE, options: [ { label: 'Prisma', description: 'Type-safe ORM' }, { label: 'Drizzle', description: 'Lightweight ORM' }, ], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', async () => { const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', async () => { const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('navigates between questions with arrow keys', async () => { const multiQuestions: Question[] = [ { question: 'Which testing framework?', header: 'Testing', type: QuestionType.CHOICE, options: [{ label: 'Vitest', description: 'Fast unit testing' }], multiSelect: false, }, { question: 'Which CI provider?', header: 'CI', type: QuestionType.CHOICE, options: [ { label: 'GitHub Actions', description: 'Built into GitHub' }, ], multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which CI provider?'); }); writeKey(stdin, '\x1b[D'); // Left arrow await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); }); }); it('preserves answers when navigating back', async () => { const multiQuestions: Question[] = [ { question: 'Which package manager?', header: 'Package', type: QuestionType.CHOICE, options: [{ label: 'pnpm', description: 'Fast, disk efficient' }], multiSelect: false, }, { question: 'Which bundler?', header: 'Bundler', type: QuestionType.CHOICE, options: [{ label: 'Vite', description: 'Next generation bundler' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Answer first question (should auto-advance) writeKey(stdin, '\r'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which bundler?'); }); // Navigate back writeKey(stdin, '\x1b[D'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which package manager?'); }); // Navigate forward writeKey(stdin, '\x1b[C'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which bundler?'); }); // Answer second question writeKey(stdin, '\r'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); }); // Submit from Review writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); }); }); it('shows Review tab in progress header for multiple questions', async () => { const multiQuestions: Question[] = [ { question: 'Which framework?', header: 'Framework', type: QuestionType.CHOICE, options: [ { label: 'React', description: 'Component library' }, { label: 'Vue', description: 'Progressive framework' }, ], multiSelect: false, }, { question: 'Which styling?', header: 'Styling', type: QuestionType.CHOICE, options: [ { label: 'Tailwind', description: 'Utility-first CSS' }, { label: 'CSS Modules', description: 'Scoped styles' }, ], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('allows navigating to Review tab and back', async () => { const multiQuestions: Question[] = [ { question: 'Create tests?', header: 'Tests', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: 'Generate test files' }], multiSelect: false, }, { question: 'Add documentation?', header: 'Docs', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: 'Generate JSDoc comments' }], multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); writeKey(stdin, '\x1b[C'); // Right arrow await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Add documentation?'); }); writeKey(stdin, '\x1b[C'); // Right arrow to Review await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); writeKey(stdin, '\x1b[D'); // Left arrow back await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Add documentation?'); }); }); it('shows warning for unanswered questions on Review tab', async () => { const multiQuestions: Question[] = [ { question: 'Which license?', header: 'License', type: QuestionType.CHOICE, options: [{ label: 'MIT', description: 'Permissive license' }], multiSelect: false, }, { question: 'Include README?', header: 'README', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: 'Generate README.md' }], multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Navigate directly to Review tab without answering writeKey(stdin, '\x1b[C'); writeKey(stdin, '\x1b[C'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); it('submits with unanswered questions when user confirms on Review', async () => { const multiQuestions: Question[] = [ { question: 'Target Node version?', header: 'Node', type: QuestionType.CHOICE, options: [{ label: 'Node 20', description: 'LTS version' }], multiSelect: false, }, { question: 'Enable strict mode?', header: 'Strict', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: 'Strict TypeScript' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin } = await renderWithProviders( , { width: 120 }, ); // Answer only first question writeKey(stdin, '\r'); // Navigate to Review tab writeKey(stdin, '\x1b[C'); // Submit writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); }); }); describe('Text type questions', () => { it('renders text input for type: "text"', async () => { const textQuestion: Question[] = [ { question: 'What should we name this component?', header: 'Name', type: QuestionType.TEXT, placeholder: 'e.g., UserProfileCard', }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows default placeholder when none provided', async () => { const textQuestion: Question[] = [ { question: 'Enter the database connection string:', header: 'Database', type: QuestionType.TEXT, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('supports backspace in text mode', async () => { const textQuestion: Question[] = [ { question: 'Enter the function name:', header: 'Function', type: QuestionType.TEXT, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); for (const char of 'abc') { writeKey(stdin, char); } await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('abc'); }); writeKey(stdin, '\x7f'); // Backspace await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('ab'); await waitUntilReady(); expect(lastFrame()).not.toContain('abc'); }); }); it('shows correct keyboard hints for text type', async () => { const textQuestion: Question[] = [ { question: 'Enter the variable name:', header: 'Variable', type: QuestionType.TEXT, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('preserves text answer when navigating between questions', async () => { const mixedQuestions: Question[] = [ { question: 'What should we name this hook?', header: 'Hook', type: QuestionType.TEXT, }, { question: 'Should it be async?', header: 'Async', type: QuestionType.CHOICE, options: [ { label: 'Yes', description: 'Use async/await' }, { label: 'No', description: 'Synchronous hook' }, ], multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); for (const char of 'useAuth') { writeKey(stdin, char); } writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Should it be async?'); }); writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('useAuth'); }); }); it('handles mixed text and choice questions', async () => { const mixedQuestions: Question[] = [ { question: 'What should we name this component?', header: 'Name', type: QuestionType.TEXT, placeholder: 'Enter component name', }, { question: 'Which styling approach?', header: 'Style', type: QuestionType.CHOICE, options: [ { label: 'CSS Modules', description: 'Scoped CSS' }, { label: 'Tailwind', description: 'Utility classes' }, ], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); for (const char of 'DataTable') { writeKey(stdin, char); } writeKey(stdin, '\r'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Which styling approach?'); }); writeKey(stdin, '\r'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); await waitUntilReady(); expect(lastFrame()).toContain('Name'); await waitUntilReady(); expect(lastFrame()).toContain('DataTable'); await waitUntilReady(); expect(lastFrame()).toContain('Style'); await waitUntilReady(); expect(lastFrame()).toContain('CSS Modules'); }); writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'DataTable', '1': 'CSS Modules', }); }); }); it('submits empty text as unanswered', async () => { const textQuestion: Question[] = [ { question: 'Enter the class name:', header: 'Class', type: QuestionType.TEXT, }, ]; const onSubmit = vi.fn(); const { stdin } = await renderWithProviders( , { width: 120 }, ); writeKey(stdin, '\r'); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({}); }); }); it('clears text on Ctrl+C', async () => { const textQuestion: Question[] = [ { question: 'Enter the class name:', header: 'Class', type: QuestionType.TEXT, }, ]; const onCancel = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); for (const char of 'SomeText') { writeKey(stdin, char); } await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('SomeText'); }); // Send Ctrl+C writeKey(stdin, '\x03'); // Ctrl+C await waitFor(async () => { // Text should be cleared await waitUntilReady(); expect(lastFrame()).not.toContain('SomeText'); await waitUntilReady(); expect(lastFrame()).toContain('>'); }); // Should NOT call onCancel (dialog should stay open) expect(onCancel).not.toHaveBeenCalled(); }); it('allows immediate arrow navigation after switching away from text input', async () => { const multiQuestions: Question[] = [ { question: 'Choice Q?', header: 'Choice', type: QuestionType.CHOICE, options: [{ label: 'Option 1', description: '' }], multiSelect: false, }, { question: 'Text Q?', header: 'Text', type: QuestionType.TEXT, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // 1. Move to Text Q (Right arrow works for Choice Q) writeKey(stdin, '\x1b[C'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); // 2. Type something in Text Q to make isEditingCustomOption true writeKey(stdin, 'a'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('a'); }); // 3. Move back to Choice Q (Left arrow works because cursor is at left edge) // When typing 'a', cursor is at index 1. // We need to move cursor to index 0 first for Left arrow to work for navigation. writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0 await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Choice Q?'); }); // 4. Immediately try Right arrow to go back to Text Q writeKey(stdin, '\x1b[C'); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); }); it('handles rapid sequential answers correctly (stale closure protection)', async () => { const multiQuestions: Question[] = [ { question: 'Question 1?', header: 'Q1', type: QuestionType.CHOICE, options: [{ label: 'A1', description: '' }], multiSelect: false, }, { question: 'Question 2?', header: 'Q2', type: QuestionType.CHOICE, options: [{ label: 'A2', description: '' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); // Answer Q1 and Q2 sequentialy act(() => { stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance }); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Question 2?'); }); act(() => { stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review }); await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); }); act(() => { stdin.write('\r'); // Submit from Review }); await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'A1', '1': 'A2', }); }); }); }); describe('Markdown rendering', () => { it('auto-bolds plain single-line questions', async () => { const questions: Question[] = [ { question: 'Which option do you prefer?', header: 'Test', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: '' }], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitFor(async () => { await waitUntilReady(); const frame = lastFrame(); // Plain text should be rendered as bold expect(frame).toContain('Which option do you prefer?'); }); }); it('does not auto-bold questions that already have markdown', async () => { const questions: Question[] = [ { question: 'Is **this** working?', header: 'Test', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: '' }], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitFor(async () => { await waitUntilReady(); const frame = lastFrame(); // Should NOT have double-bold (the whole question bolded AND "this" bolded) // "Is " should not be bold, only "this" should be bold expect(frame).toContain('Is '); expect(frame).toContain('this'); expect(frame).not.toContain('**this**'); }); }); it('renders bold markdown in question', async () => { const questions: Question[] = [ { question: 'Is **this** working?', header: 'Test', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: '' }], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitFor(async () => { await waitUntilReady(); const frame = lastFrame(); // Check for 'this' - asterisks should be gone expect(frame).toContain('this'); expect(frame).not.toContain('**this**'); }); }); it('renders inline code markdown in question', async () => { const questions: Question[] = [ { question: 'Run `npm start`?', header: 'Test', type: QuestionType.CHOICE, options: [{ label: 'Yes', description: '' }], multiSelect: false, }, ]; const { lastFrame, waitUntilReady } = await renderWithProviders( , { width: 120 }, ); await waitFor(async () => { await waitUntilReady(); const frame = lastFrame(); // Backticks should be removed expect(frame).toContain('Run npm start?'); expect(frame).not.toContain('`'); }); }); }); it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => { const questions: Question[] = [ { question: 'Choose an option', header: 'Context Test', type: QuestionType.CHOICE, options: Array.from({ length: 10 }, (_, i) => ({ label: `Option ${i + 1}`, description: `Description ${i + 1}`, })), multiSelect: false, }, ]; const mockUIState = { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; const { lastFrame, waitUntilReady } = await renderWithProviders( , { config: makeFakeConfig({ useAlternateBuffer: false }), settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) await waitUntilReady(); expect(lastFrame()).toContain('▲'); await waitUntilReady(); expect(lastFrame()).toContain('▼'); }); it('does NOT truncate the question when in alternate buffer mode even with small height', async () => { const longQuestion = 'This is a very long question ' + 'with many words '.repeat(10); const questions: Question[] = [ { question: longQuestion, header: 'Alternate Buffer Test', type: QuestionType.CHOICE, options: [{ label: 'Option 1', description: 'Desc 1' }], multiSelect: false, }, ]; const mockUIState = { availableTerminalHeight: 5, } as UIState; const { lastFrame, waitUntilReady } = await renderWithProviders( , { config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); // Should NOT contain the truncation message await waitUntilReady(); expect(lastFrame()).not.toContain('hidden ...'); // Should contain the full long question (or at least its parts) await waitUntilReady(); expect(lastFrame()).toContain('This is a very long question'); }); describe('Choice question placeholder', () => { it('uses placeholder for "Other" option when provided', async () => { const questions: Question[] = [ { question: 'Select your preferred language:', header: 'Language', type: QuestionType.CHOICE, options: [ { label: 'TypeScript', description: '' }, { label: 'JavaScript', description: '' }, ], placeholder: 'Type another language...', multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 80 }, ); // Navigate to the "Other" option writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\x1b[B'); // Down to Other await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); it('uses default placeholder when not provided', async () => { const questions: Question[] = [ { question: 'Select your preferred language:', header: 'Language', type: QuestionType.CHOICE, options: [ { label: 'TypeScript', description: '' }, { label: 'JavaScript', description: '' }, ], multiSelect: false, }, ]; const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( , { width: 80 }, ); // Navigate to the "Other" option writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\x1b[B'); // Down to Other await waitFor(async () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); }); it('expands paste placeholders in multi-select custom option via Done', async () => { const questions: Question[] = [ { question: 'Which features?', header: 'Features', type: QuestionType.CHOICE, options: [{ label: 'TypeScript', description: '' }], multiSelect: true, }, ]; const onSubmit = vi.fn(); const { stdin } = await renderWithProviders( , { width: 120 }, ); // Select TypeScript writeKey(stdin, '\r'); // Down to Other writeKey(stdin, '\x1b[B'); // Simulate bracketed paste of multi-line text into the custom option const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; const ESC = '\x1b'; writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); // Down to Done and submit writeKey(stdin, '\x1b[B'); writeKey(stdin, '\r'); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': `TypeScript, ${pastedText}`, }); }); }); }); ================================================ FILE: packages/cli/src/ui/components/AskUserDialog.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useCallback, useMemo, useRef, useEffect, useReducer, useContext, } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { checkExhaustive, type Question } from '@google/gemini-cli-core'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; import type { SelectionListItem } from '../hooks/useSelectionList.js'; import { TabHeader, type Tab } from './shared/TabHeader.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; import { Command } from '../key/keyMatchers.js'; import { TextInput } from './shared/TextInput.js'; import { formatCommand } from '../key/keybindingUtils.js'; import { useTextBuffer, expandPastePlaceholders, } from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** Padding for dialog content to prevent text from touching edges. */ const DIALOG_PADDING = 4; /** * Checks if text is a single line without markdown identifiers. */ function isPlainSingleLine(text: string): boolean { // Must be a single line (no newlines) if (text.includes('\n') || text.includes('\r')) { return false; } // Check for common markdown identifiers const markdownPatterns = [ /^#{1,6}\s/, // Headers /^[`~]{3,}/, // Code fences /^[-*+]\s/, // Unordered lists /^\d+\.\s/, // Ordered lists /^[-*_]{3,}$/, // Horizontal rules /\|/, // Tables /\*\*|__/, // Bold /(? void; /** * Callback fired when the user cancels the dialog (e.g. via Escape). */ onCancel: () => void; /** * Optional callback to notify parent when text input is active. * Useful for managing global keypress handlers. */ onActiveTextInputChange?: (active: boolean) => void; /** * Width of the dialog. */ width: number; /** * Height constraint for scrollable content. */ availableHeight?: number; /** * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */ extraParts?: string[]; } interface ReviewViewProps { questions: Question[]; answers: { [key: string]: string }; onSubmit: () => void; progressHeader?: React.ReactNode; extraParts?: string[]; } const ReviewView: React.FC = ({ questions, answers, onSubmit, progressHeader, extraParts, }) => { const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; // Handle Enter to submit useKeypress( (key: Key) => { if (keyMatchers[Command.RETURN](key)) { onSubmit(); return true; } return false; }, { isActive: true }, ); return ( {progressHeader} Review your answers: {hasUnanswered && ( ⚠ You have {unansweredCount} unanswered question {unansweredCount > 1 ? 's' : ''} )} {questions.map((q, i) => ( {q.header} {answers[i] || '(not answered)'} ))} ); }; // ============== Text Question View ============== interface TextQuestionViewProps { question: Question; onAnswer: (answer: string) => void; onSelectionChange?: (answer: string) => void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; availableHeight?: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; } const TextQuestionView: React.FC = ({ question, onAnswer, onSelectionChange, onEditingCustomOption, availableWidth, availableHeight, initialAnswer, progressHeader, keyboardHints, }) => { const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor const bufferWidth = availableWidth - getCachedStringWidth(prefix) - horizontalPadding; const buffer = useTextBuffer({ initialText: initialAnswer, viewport: { width: Math.max(1, bufferWidth), height: 3 }, singleLine: false, }); const { text: textValue } = buffer; // Sync state change with parent - only when it actually changes const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { onSelectionChange?.( expandPastePlaceholders(textValue, buffer.pastedContent), ); lastTextValueRef.current = textValue; } }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( (key: Key) => { if (keyMatchers[Command.QUIT](key)) { if (textValue === '') { return false; } buffer.setText(''); return true; } return false; }, [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); const handleSubmit = useCallback( (val: string) => { onAnswer(val.trim()); }, [onAnswer], ); // Notify parent that we're in text input mode (for Ctrl+C handling) useEffect(() => { onEditingCustomOption?.(true); return () => { onEditingCustomOption?.(false); }; }, [onEditingCustomOption]); const placeholder = question.placeholder || 'Enter your response'; const HEADER_HEIGHT = progressHeader ? 2 : 0; const INPUT_HEIGHT = 2; // TextInput + margin const FOOTER_HEIGHT = 2; // DialogFooter + margin const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; const questionHeight = availableHeight && !isAlternateBuffer ? Math.max(1, availableHeight - overhead) : undefined; return ( {progressHeader} {'> '} {keyboardHints} ); }; // ============== Choice Question View ============== interface OptionItem { key: string; label: string; description: string; type: 'option' | 'other' | 'done' | 'all'; index: number; } interface ChoiceQuestionState { selectedIndices: Set; isCustomOptionSelected: boolean; isCustomOptionFocused: boolean; } type ChoiceQuestionAction = | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } | { type: 'TOGGLE_ALL'; payload: { totalOptions: number } } | { type: 'SET_CUSTOM_SELECTED'; payload: { selected: boolean; multiSelect: boolean }; } | { type: 'TOGGLE_CUSTOM_SELECTED'; payload: { multiSelect: boolean } } | { type: 'SET_CUSTOM_FOCUSED'; payload: { focused: boolean } }; function choiceQuestionReducer( state: ChoiceQuestionState, action: ChoiceQuestionAction, ): ChoiceQuestionState { switch (action.type) { case 'TOGGLE_ALL': { const { totalOptions } = action.payload; const allSelected = state.selectedIndices.size === totalOptions; if (allSelected) { return { ...state, selectedIndices: new Set(), }; } else { const newIndices = new Set(); for (let i = 0; i < totalOptions; i++) { newIndices.add(i); } return { ...state, selectedIndices: newIndices, }; } } case 'TOGGLE_INDEX': { const { index, multiSelect } = action.payload; const newIndices = new Set(multiSelect ? state.selectedIndices : []); if (newIndices.has(index)) { newIndices.delete(index); } else { newIndices.add(index); } return { ...state, selectedIndices: newIndices, // In single select, selecting an option deselects custom isCustomOptionSelected: multiSelect ? state.isCustomOptionSelected : false, }; } case 'SET_CUSTOM_SELECTED': { const { selected, multiSelect } = action.payload; return { ...state, isCustomOptionSelected: selected, // In single-select, selecting custom deselects others selectedIndices: multiSelect ? state.selectedIndices : new Set(), }; } case 'TOGGLE_CUSTOM_SELECTED': { const { multiSelect } = action.payload; if (!multiSelect) return state; return { ...state, isCustomOptionSelected: !state.isCustomOptionSelected, }; } case 'SET_CUSTOM_FOCUSED': { return { ...state, isCustomOptionFocused: action.payload.focused, }; } default: checkExhaustive(action); return state; } } interface ChoiceQuestionViewProps { question: Question; onAnswer: (answer: string) => void; onSelectionChange?: (answer: string) => void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; availableHeight?: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; } const ChoiceQuestionView: React.FC = ({ question, onAnswer, onSelectionChange, onEditingCustomOption, availableWidth, availableHeight, initialAnswer, progressHeader, keyboardHints, }) => { const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); const numLen = String(numOptions).length; const radioWidth = 2; // "● " const numberWidth = numLen + 2; // e.g., "1. " const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " " const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓" const cursorPadding = 1; // Extra character for cursor at end of line const horizontalPadding = radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding; const bufferWidth = availableWidth - horizontalPadding; const questionOptions = useMemo( () => question.options ?? [], [question.options], ); // Initialize state from initialAnswer if returning to a previously answered question const initialReducerState = useMemo((): ChoiceQuestionState => { if (!initialAnswer) { return { selectedIndices: new Set(), isCustomOptionSelected: false, isCustomOptionFocused: false, }; } // Check if initialAnswer matches any option labels const selectedIndices = new Set(); let isCustomOptionSelected = false; if (question.multiSelect) { const answers = initialAnswer.split(', '); answers.forEach((answer) => { const index = questionOptions.findIndex((opt) => opt.label === answer); if (index !== -1) { selectedIndices.add(index); } else { isCustomOptionSelected = true; } }); } else { const index = questionOptions.findIndex( (opt) => opt.label === initialAnswer, ); if (index !== -1) { selectedIndices.add(index); } else { isCustomOptionSelected = true; } } return { selectedIndices, isCustomOptionSelected, isCustomOptionFocused: false, }; }, [initialAnswer, questionOptions, question.multiSelect]); const [state, dispatch] = useReducer( choiceQuestionReducer, initialReducerState, ); const { selectedIndices, isCustomOptionSelected, isCustomOptionFocused } = state; const initialCustomText = useMemo(() => { if (!initialAnswer) return ''; if (question.multiSelect) { const answers = initialAnswer.split(', '); const custom = answers.find( (a) => !questionOptions.some((opt) => opt.label === a), ); return custom || ''; } else { const isPredefined = questionOptions.some( (opt) => opt.label === initialAnswer, ); return isPredefined ? '' : initialAnswer; } }, [initialAnswer, questionOptions, question.multiSelect]); const customBuffer = useTextBuffer({ initialText: initialCustomText, viewport: { width: Math.max(1, bufferWidth), height: 3 }, singleLine: false, }); const customOptionText = customBuffer.text; // Helper to build answer string from selections const buildAnswerString = useCallback( ( indices: Set, includeCustomOption: boolean, customOption: string, ) => { const answers: string[] = []; questionOptions.forEach((opt, i) => { if (indices.has(i)) { answers.push(opt.label); } }); if (includeCustomOption && customOption.trim()) { const expanded = expandPastePlaceholders( customOption, customBuffer.pastedContent, ); answers.push(expanded.trim()); } return answers.join(', '); }, [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes const lastBuiltAnswerRef = useRef(''); useEffect(() => { const newAnswer = buildAnswerString( selectedIndices, isCustomOptionSelected, customOptionText, ); if (newAnswer !== lastBuiltAnswerRef.current) { onSelectionChange?.(newAnswer); lastBuiltAnswerRef.current = newAnswer; } }, [ selectedIndices, isCustomOptionSelected, customOptionText, buildAnswerString, onSelectionChange, ]); // Handle "Type-to-Jump" and Ctrl+C for custom buffer const handleExtraKeys = useCallback( (key: Key) => { // If focusing custom option, handle Ctrl+C if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { if (customOptionText === '') { return false; } customBuffer.setText(''); return true; } // Don't jump if a navigation or selection key is pressed if ( keyMatchers[Command.DIALOG_NAVIGATION_UP](key) || keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) || keyMatchers[Command.DIALOG_NEXT](key) || keyMatchers[Command.DIALOG_PREV](key) || keyMatchers[Command.MOVE_LEFT](key) || keyMatchers[Command.MOVE_RIGHT](key) || keyMatchers[Command.RETURN](key) || keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key) ) { return false; } // Check if it's a numeric quick selection key (if numbers are shown) const isNumeric = /^[0-9]$/.test(key.sequence); if (isNumeric) { return false; } // Type-to-jump: if printable characters are typed and not focused, jump to custom const isPrintable = key.sequence && !key.ctrl && !key.alt && (key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32); if (isPrintable && !isCustomOptionFocused) { dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } }); onEditingCustomOption?.(true); // For IME or multi-char sequences, we want to capture the whole thing. // If it's a single char, we start the buffer with it. customBuffer.setText(key.sequence); return true; } return false; }, [ isCustomOptionFocused, customBuffer, onEditingCustomOption, customOptionText, keyMatchers, ], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); const selectionItems = useMemo((): Array> => { const list: Array> = questionOptions.map( (opt, i) => { const item: OptionItem = { key: `opt-${i}`, label: opt.label, description: opt.description, type: 'option', index: i, }; return { key: item.key, value: item }; }, ); // Add 'All of the above' for multi-select if (question.multiSelect && questionOptions.length > 1) { const allItem: OptionItem = { key: 'all', label: 'All of the above', description: 'Select all options', type: 'all', index: list.length, }; list.push({ key: 'all', value: allItem }); } // Only add custom option for choice type, not yesno if (question.type !== 'yesno') { const otherItem: OptionItem = { key: 'other', label: customOptionText || '', description: '', type: 'other', index: list.length, }; list.push({ key: 'other', value: otherItem }); } if (question.multiSelect) { const doneItem: OptionItem = { key: 'done', label: 'Done', description: 'Finish selection', type: 'done', index: list.length, }; list.push({ key: doneItem.key, value: doneItem, hideNumber: true }); } return list; }, [questionOptions, question.multiSelect, question.type, customOptionText]); const handleHighlight = useCallback( (itemValue: OptionItem) => { const nowFocusingCustomOption = itemValue.type === 'other'; dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: nowFocusingCustomOption }, }); // Notify parent when we start/stop focusing custom option (so navigation can resume) onEditingCustomOption?.(nowFocusingCustomOption); }, [onEditingCustomOption], ); const handleSelect = useCallback( (itemValue: OptionItem) => { if (question.multiSelect) { if (itemValue.type === 'option') { dispatch({ type: 'TOGGLE_INDEX', payload: { index: itemValue.index, multiSelect: true }, }); } else if (itemValue.type === 'other') { dispatch({ type: 'TOGGLE_CUSTOM_SELECTED', payload: { multiSelect: true }, }); } else if (itemValue.type === 'all') { dispatch({ type: 'TOGGLE_ALL', payload: { totalOptions: questionOptions.length }, }); } else if (itemValue.type === 'done') { // Done just triggers navigation, selections already saved via useEffect onAnswer( buildAnswerString( selectedIndices, isCustomOptionSelected, customOptionText, ), ); } } else { if (itemValue.type === 'option') { onAnswer(itemValue.label); } else if (itemValue.type === 'other') { // In single select, selecting other submits it if it has text if (customOptionText.trim()) { onAnswer( expandPastePlaceholders( customOptionText, customBuffer.pastedContent, ).trim(), ); } } } }, [ question.multiSelect, questionOptions.length, selectedIndices, isCustomOptionSelected, customOptionText, customBuffer.pastedContent, onAnswer, buildAnswerString, ], ); // Auto-select custom option when typing in it useEffect(() => { if (customOptionText.trim() && !isCustomOptionSelected) { dispatch({ type: 'SET_CUSTOM_SELECTED', payload: { selected: true, multiSelect: !!question.multiSelect }, }); } }, [customOptionText, isCustomOptionSelected, question.multiSelect]); const HEADER_HEIGHT = progressHeader ? 2 : 0; const TITLE_MARGIN = 1; const FOOTER_HEIGHT = 2; // DialogFooter + margin const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; const listHeight = availableHeight ? Math.max(1, availableHeight - overhead) : undefined; const questionHeightLimit = listHeight && !isAlternateBuffer ? question.unconstrainedHeight ? Math.max(1, listHeight - selectionItems.length * 2) : Math.min(15, Math.max(1, listHeight - DIALOG_PADDING)) : undefined; const maxItemsToShow = listHeight && questionHeightLimit ? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2)) : selectionItems.length; return ( {progressHeader} {question.multiSelect && ( (Select all that apply) )} items={selectionItems} onSelect={handleSelect} onHighlight={handleHighlight} focusKey={isCustomOptionFocused ? 'other' : undefined} maxItemsToShow={maxItemsToShow} showScrollArrows={true} renderItem={(item, context) => { const optionItem = item.value; const isChecked = (optionItem.type === 'option' && selectedIndices.has(optionItem.index)) || (optionItem.type === 'other' && isCustomOptionSelected) || (optionItem.type === 'all' && selectedIndices.size === questionOptions.length); const showCheck = question.multiSelect && (optionItem.type === 'option' || optionItem.type === 'other' || optionItem.type === 'all'); // Render inline text input for custom option if (optionItem.type === 'other') { const placeholder = question.placeholder || 'Enter a custom value'; return ( {showCheck && ( [{isChecked ? 'x' : ' '}] )} { if (question.multiSelect) { const fullAnswer = buildAnswerString( selectedIndices, true, val, ); if (fullAnswer) { onAnswer(fullAnswer); } } else if (val.trim()) { onAnswer(val.trim()); } }} /> {isChecked && !question.multiSelect && !context.isSelected && ( )} ); } // Determine label color: checked (previously answered) uses success, selected uses accent, else primary const labelColor = isChecked && !question.multiSelect ? theme.status.success : context.isSelected ? context.titleColor : theme.text.primary; return ( {showCheck && ( [{isChecked ? 'x' : ' '}] )} {' '} {optionItem.label} {isChecked && !question.multiSelect && ( )} {optionItem.description && ( {' '} )} ); }} /> {keyboardHints} ); }; export const AskUserDialog: React.FC = ({ questions, onSubmit, onCancel, onActiveTextInputChange, width, availableHeight: availableHeightProp, extraParts, }) => { const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? (uiState?.constrainHeight !== false ? uiState?.availableTerminalHeight : undefined); const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); const { answers, isEditingCustomOption, submitted } = state; const reviewTabIndex = questions.length; const tabCount = questions.length > 1 ? questions.length + 1 : questions.length; const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({ tabCount, isActive: !submitted && questions.length > 1, enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually enableTabKey: false, // We'll handle tab manually to match existing behavior }); const currentQuestionIndex = currentIndex; const handleEditingCustomOption = useCallback((isEditing: boolean) => { dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } }); }, []); useEffect(() => { onActiveTextInputChange?.(isEditingCustomOption); return () => { onActiveTextInputChange?.(false); }; }, [isEditingCustomOption, onActiveTextInputChange]); const handleCancel = useCallback( (key: Key) => { if (submitted) return false; if (keyMatchers[Command.ESCAPE](key)) { onCancel(); return true; } else if (keyMatchers[Command.QUIT](key)) { if (!isEditingCustomOption) { onCancel(); } // Return false to let ctrl-C bubble up to AppContainer for exit flow return false; } return false; }, [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { isActive: !submitted, }); const isOnReviewTab = currentQuestionIndex === reviewTabIndex; const handleNavigation = useCallback( (key: Key) => { if (submitted || questions.length <= 1) return false; const isNextKey = keyMatchers[Command.DIALOG_NEXT](key); const isPrevKey = keyMatchers[Command.DIALOG_PREV](key); const isRight = keyMatchers[Command.MOVE_RIGHT](key); const isLeft = keyMatchers[Command.MOVE_LEFT](key); // Tab keys always trigger navigation. // Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge). const shouldGoNext = isNextKey || isRight; const shouldGoPrev = isPrevKey || isLeft; if (shouldGoNext) { goToNextTab(); return true; } else if (shouldGoPrev) { goToPrevTab(); return true; } return false; }, [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { isActive: questions.length > 1 && !submitted, }); useEffect(() => { if (submitted) { onSubmit(answers); } }, [submitted, answers, onSubmit]); const handleAnswer = useCallback( (answer: string) => { if (submitted) return; if (questions.length > 1) { dispatch({ type: 'SET_ANSWER', payload: { index: currentQuestionIndex, answer, }, }); goToNextTab(); } else { dispatch({ type: 'SET_ANSWER', payload: { index: currentQuestionIndex, answer, submit: true, }, }); } }, [currentQuestionIndex, questions, submitted, goToNextTab], ); const handleReviewSubmit = useCallback(() => { if (submitted) return; dispatch({ type: 'SUBMIT' }); }, [submitted]); const handleSelectionChange = useCallback( (answer: string) => { if (submitted) return; dispatch({ type: 'SET_ANSWER', payload: { index: currentQuestionIndex, answer, }, }); }, [submitted, currentQuestionIndex], ); const answeredIndices = useMemo( () => new Set(Object.keys(answers).map(Number)), [answers], ); const currentQuestion = questions[currentQuestionIndex]; const effectiveQuestion = useMemo(() => { if (currentQuestion?.type === 'yesno') { return { ...currentQuestion, options: [ { label: 'Yes', description: '' }, { label: 'No', description: '' }, ], multiSelect: false, }; } return currentQuestion; }, [currentQuestion]); const tabs = useMemo((): Tab[] => { const questionTabs: Tab[] = questions.map((q, i) => ({ key: String(i), header: q.header, })); if (questions.length > 1) { questionTabs.push({ key: 'review', header: 'Review', isSpecial: true, }); } return questionTabs; }, [questions]); const progressHeader = questions.length > 1 ? ( ) : null; if (isOnReviewTab) { return ( ); } if (!currentQuestion) return null; const keyboardHints = ( 1 ? currentQuestion.type === 'text' || isEditingCustomOption ? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions` : '←/→ to switch questions' : currentQuestion.type === 'text' || isEditingCustomOption ? undefined : '↑/↓ to navigate' } extraParts={extraParts} /> ); const questionView = currentQuestion.type === 'text' ? ( ) : ( ); return ( {questionView} ); }; ================================================ FILE: packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { act } from 'react'; import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; import { ScrollProvider } from '../contexts/ScrollProvider.js'; import { Box } from 'ink'; // Mock dependencies const mockDismissBackgroundShell = vi.fn(); const mockSetActiveBackgroundShellPid = vi.fn(); const mockSetIsBackgroundShellListOpen = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ dismissBackgroundShell: mockDismissBackgroundShell, setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), getLogFilePath: vi.fn( (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, ), getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), }, }; }); // Mock AnsiOutputText since it's a complex component vi.mock('./AnsiOutput.js', () => ({ AnsiOutputText: ({ data }: { data: string | unknown }) => { if (typeof data === 'string') return <>{data}; // Simple serialization for object data return <>{JSON.stringify(data)}; }, })); // Mock useKeypress let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> = []; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn((handler, { isActive }) => { keypressHandlers.push({ handler, isActive }); }), })); const simulateKey = (key: Partial) => { const fullKey: Key = createMockKey(key); keypressHandlers.forEach(({ handler, isActive }) => { if (isActive) { handler(fullKey); } }); }; vi.mock('../contexts/MouseContext.js', () => ({ useMouseContext: vi.fn(() => ({ subscribe: vi.fn(), unsubscribe: vi.fn(), })), useMouse: vi.fn(), })); // Mock ScrollableList vi.mock('./shared/ScrollableList.js', () => ({ SCROLL_TO_ITEM_END: 999999, ScrollableList: vi.fn( ({ data, renderItem, }: { data: BackgroundShell[]; renderItem: (props: { item: BackgroundShell; index: number; }) => React.ReactNode; }) => ( {data.map((item: BackgroundShell, index: number) => ( {renderItem({ item, index })} ))} ), ), })); afterEach(() => { vi.restoreAllMocks(); }); const createMockKey = (overrides: Partial): Key => ({ name: '', ctrl: false, alt: false, cmd: false, shift: false, insertable: false, sequence: '', ...overrides, }); describe('', () => { const mockShells = new Map(); const shell1: BackgroundShell = { pid: 1001, command: 'npm start', output: 'Starting server...', isBinary: false, binaryBytesReceived: 0, status: 'running', }; const shell2: BackgroundShell = { pid: 1002, command: 'tail -f log.txt', output: 'Log entry 1', isBinary: false, binaryBytesReceived: 0, status: 'running', }; beforeEach(() => { vi.clearAllMocks(); mockShells.clear(); mockShells.set(shell1.pid, shell1); mockShells.set(shell2.pid, shell2); keypressHandlers = []; }); it('renders the output of the active shell', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders tabs for multiple shells', async () => { const width = 100; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('highlights the focused state', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; const { rerender, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, 20, ); rerender( , ); await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, 26, ); unmount(); }); it('renders the process list when isListOpenProp is true', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); unmount(); }); it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); // Initial state: shell1 (active) is highlighted // Move to shell2 await act(async () => { simulateKey({ name: 'down' }); }); await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); }); it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); }); it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('keeps exit code status color even when selected', async () => { const exitedShell: BackgroundShell = { pid: 1003, command: 'exit 0', output: '', isBinary: false, binaryBytesReceived: 0, status: 'exited', exitCode: 0, }; mockShells.set(exitedShell.pid, exitedShell); const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/BackgroundShellDisplay.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import { useEffect, useState, useRef } from 'react'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { theme } from '../semantic-colors.js'; import { ShellExecutionService, shortenPath, tildeifyPath, type AnsiOutput, type AnsiLine, type AnsiToken, } from '@google/gemini-cli-core'; import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { Command } from '../key/keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { formatCommand } from '../key/keybindingUtils.js'; import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; import { RadioButtonSelect, type RadioSelectItem, } from './shared/RadioButtonSelect.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface BackgroundShellDisplayProps { shells: Map; activePid: number; width: number; height: number; isFocused: boolean; isListOpenProp: boolean; } const CONTENT_PADDING_X = 1; const BORDER_WIDTH = 2; // Left and Right border const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border const HEADER_HEIGHT = 1; const FOOTER_HEIGHT = 1; const TOTAL_OVERHEAD_HEIGHT = MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom const TAB_DISPLAY_HORIZONTAL_PADDING = 4; const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) const formatShellCommandForDisplay = (command: string, maxWidth: number) => { const commandFirstLine = command.split('\n')[0]; return cpLen(commandFirstLine) > maxWidth ? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...` : commandFirstLine; }; export const BackgroundShellDisplay = ({ shells, activePid, width, height, isFocused, isListOpenProp, }: BackgroundShellDisplayProps) => { const keyMatchers = useKeyMatchers(); const { dismissBackgroundShell, setActiveBackgroundShellPid, setIsBackgroundShellListOpen, } = useUIActions(); const activeShell = shells.get(activePid); const [output, setOutput] = useState( activeShell?.output || '', ); const [highlightedPid, setHighlightedPid] = useState( activePid, ); const outputRef = useRef>(null); const subscribedRef = useRef(false); useEffect(() => { if (!activePid) return; const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); }, [activePid, width, height]); useEffect(() => { if (!activePid) { setOutput(''); return; } // Set initial output from the shell object const shell = shells.get(activePid); if (shell) { setOutput(shell.output); } subscribedRef.current = false; // Subscribe to live updates for the active shell const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => { if (event.type === 'data') { if (typeof event.chunk === 'string') { if (!subscribedRef.current) { // Initial synchronous update contains full history setOutput(event.chunk); } else { // Subsequent updates are deltas for child_process setOutput((prev) => typeof prev === 'string' ? prev + event.chunk : event.chunk, ); } } else { // PTY always sends full AnsiOutput setOutput(event.chunk); } } }); subscribedRef.current = true; return () => { unsubscribe(); subscribedRef.current = false; }; }, [activePid, shells]); // Sync highlightedPid with activePid when list opens useEffect(() => { if (isListOpenProp) { setHighlightedPid(activePid); } }, [isListOpenProp, activePid]); useKeypress( (key) => { if (!activeShell) return; if (isListOpenProp) { // Navigation (Up/Down/Enter) is handled by RadioButtonSelect // We only handle special keys not consumed by RadioButtonSelect or overriding them if needed // RadioButtonSelect handles Enter -> onSelect if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { setIsBackgroundShellListOpen(false); return true; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { void dismissBackgroundShell(highlightedPid); // If we killed the active one, the list might update via props } return true; } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { if (highlightedPid) { setActiveBackgroundShellPid(highlightedPid); } setIsBackgroundShellListOpen(false); return true; } return false; } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { return false; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { void dismissBackgroundShell(activeShell.pid); return true; } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { setIsBackgroundShellListOpen(true); return true; } if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) { ShellExecutionService.writeToPty(activeShell.pid, '\r'); return true; } else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { ShellExecutionService.writeToPty(activeShell.pid, '\b'); return true; } else if (key.sequence) { ShellExecutionService.writeToPty(activeShell.pid, key.sequence); return true; } return false; }, { isActive: isFocused && !!activeShell }, ); const helpTextParts = [ { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, ]; const helpTextStr = helpTextParts .map((p) => `${p.label} (${formatCommand(p.command)})`) .join(' | '); const renderHelpText = () => ( {helpTextParts.map((p, i) => ( {i > 0 ? ' | ' : ''} {p.label} ( {formatCommand(p.command)}) ))} ); const renderTabs = () => { const shellList = Array.from(shells.values()).filter( (s) => s.status === 'running', ); const pidInfoWidth = getCachedStringWidth( ` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`, ); const availableWidth = width - TAB_DISPLAY_HORIZONTAL_PADDING - getCachedStringWidth(helpTextStr) - pidInfoWidth; let currentWidth = 0; const tabs = []; for (let i = 0; i < shellList.length; i++) { const shell = shellList[i]; // Account for " i: " (length 4 if i < 9) and spaces (length 2) const labelOverhead = 4 + (i + 1).toString().length; const maxTabLabelLength = Math.max( 1, Math.floor(availableWidth / shellList.length) - labelOverhead, ); const truncatedCommand = formatShellCommandForDisplay( shell.command, maxTabLabelLength, ); const label = ` ${i + 1}: ${truncatedCommand} `; const labelWidth = getCachedStringWidth(label); // If this is the only shell, we MUST show it (truncated if necessary) // even if it exceeds availableWidth, as there are no alternatives. if (i > 0 && currentWidth + labelWidth > availableWidth) { break; } const isActive = shell.pid === activePid; tabs.push( {label} , ); currentWidth += labelWidth; } if (shellList.length > tabs.length && !isListOpenProp) { const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `; const overflowWidth = getCachedStringWidth(overflowLabel); // If we only have one tab, ensure we don't show the overflow if it's too cramped // We want at least 10 chars for the overflow or we favor the first tab. const shouldShowOverflow = tabs.length > 1 || availableWidth - currentWidth >= overflowWidth; if (shouldShowOverflow) { tabs.push( {overflowLabel} , ); } } return tabs; }; const renderProcessList = () => { const maxCommandLength = Math.max( 0, width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10, ); const items: Array> = Array.from( shells.values(), ).map((shell, index) => { const truncatedCommand = formatShellCommandForDisplay( shell.command, maxCommandLength, ); let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`; if (shell.status === 'exited') { label += ` (Exit Code: ${shell.exitCode})`; } return { key: shell.pid.toString(), value: shell.pid, label, }; }); const initialIndex = items.findIndex((item) => item.value === activePid); return ( {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`} = 0 ? initialIndex : 0} onSelect={(pid) => { setActiveBackgroundShellPid(pid); setIsBackgroundShellListOpen(false); }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} maxItemsToShow={Math.max( 1, height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, )} renderItem={( item, { isSelected: _isSelected, titleColor: _titleColor }, ) => { // Custom render to handle exit code coloring if needed, // or just use default. The default RadioButtonSelect renderer // handles standard label. // But we want to color exit code differently? // The previous implementation colored exit code green/red. // Let's reimplement that. // We need access to shell details here. // We can put shell details in the item or lookup. // Lookup from shells map. const shell = shells.get(item.value); if (!shell) return {item.label}; const truncatedCommand = formatShellCommandForDisplay( shell.command, maxCommandLength, ); return ( {truncatedCommand} (PID: {shell.pid}) {shell.status === 'exited' ? ( {' '} (Exit Code: {shell.exitCode}) ) : null} ); }} /> ); }; const renderFooter = () => { const pidToDisplay = isListOpenProp ? (highlightedPid ?? activePid) : activePid; if (!pidToDisplay) return null; const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); const displayPath = shortenPath( tildeifyPath(logPath), width - LOG_PATH_OVERHEAD, ); return ( Log: {displayPath} ); }; const renderOutput = () => { const lines = typeof output === 'string' ? output.split('\n') : output; return ( { if (typeof line === 'string') { return {line}; } return ( {line.length > 0 ? line.map((token: AnsiToken, tokenIndex: number) => ( {token.text} )) : null} ); }} estimatedItemHeight={() => 1} keyExtractor={(_, index) => index.toString()} hasFocus={isFocused} initialScrollIndex={SCROLL_TO_ITEM_END} /> ); }; return ( {renderTabs()} {' '} (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} {renderHelpText()} {isListOpenProp ? renderProcessList() : renderOutput()} {renderFooter()} ); }; ================================================ FILE: packages/cli/src/ui/components/Banner.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { Banner } from './Banner.js'; import { describe, it, expect } from 'vitest'; describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { const renderResult = await renderWithProviders( , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); renderResult.unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; const renderResult = await renderWithProviders( , ); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); renderResult.unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/Banner.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import { ThemedGradient } from './ThemedGradient.js'; import { theme } from '../semantic-colors.js'; import type { ReactNode } from 'react'; export function getFormattedBannerContent( rawText: string, isWarning: boolean, subsequentLineColor: string, ): ReactNode { const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { if (isWarning) { return ( {line} ); } return ( {line} ); } return ( {line} ); }); } interface BannerProps { bannerText: string; isWarning: boolean; width: number; } export const Banner = ({ bannerText, isWarning, width }: BannerProps) => { const subsequentLineColor = theme.text.primary; const formattedBannerContent = getFormattedBannerContent( bannerText, isWarning, subsequentLineColor, ); return ( {formattedBannerContent} ); }; ================================================ FILE: packages/cli/src/ui/components/BubblingRegression.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; describe('Key Bubbling Regression', () => { afterEach(() => { vi.restoreAllMocks(); }); const choiceQuestion: Question[] = [ { question: 'Choice Q?', header: 'Choice', type: QuestionType.CHOICE, options: [ { label: 'Option 1', description: '' }, { label: 'Option 2', description: '' }, ], multiSelect: false, }, ]; it('does not navigate when pressing "j" or "k" in a focused text input', async () => { const { stdin, lastFrame } = await renderWithProviders( , { width: 120 }, ); // 1. Move down to "Enter a custom value" (3rd item) act(() => { stdin.write('\x1b[B'); // Down arrow to Option 2 }); act(() => { stdin.write('\x1b[B'); // Down arrow to Custom }); await waitFor(() => { expect(lastFrame()).toContain('Enter a custom value'); }); // 2. Type "j" act(() => { stdin.write('j'); }); await waitFor(() => { expect(lastFrame()).toContain('j'); // Verify we are still focusing the custom option (3rd item in list) expect(lastFrame()).toMatch(/● 3\.\s+j/); }); // 3. Type "k" act(() => { stdin.write('k'); }); await waitFor(() => { expect(lastFrame()).toContain('jk'); expect(lastFrame()).toMatch(/● 3\.\s+jk/); }); }); }); ================================================ FILE: packages/cli/src/ui/components/Checklist.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { Checklist } from './Checklist.js'; import type { ChecklistItemData } from './ChecklistItem.js'; describe('', () => { const items: ChecklistItemData[] = [ { status: 'completed', label: 'Task 1' }, { status: 'in_progress', label: 'Task 2' }, { status: 'pending', label: 'Task 3' }, { status: 'cancelled', label: 'Task 4' }, ]; it('renders nothing when list is empty', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders nothing when collapsed and no active items', async () => { const inactiveItems: ChecklistItemData[] = [ { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders summary view correctly (collapsed)', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders expanded view correctly', async () => { const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders summary view without in-progress item if none exists', async () => { const pendingItems: ChecklistItemData[] = [ { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); ================================================ FILE: packages/cli/src/ui/components/Checklist.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; export interface ChecklistProps { title: string; items: ChecklistItemData[]; isExpanded: boolean; toggleHint?: string; } const ChecklistTitleDisplay: React.FC<{ title: string; items: ChecklistItemData[]; toggleHint?: string; }> = ({ title, items, toggleHint }) => { const score = useMemo(() => { let total = 0; let completed = 0; for (const item of items) { if (item.status !== 'cancelled') { total += 1; if (item.status === 'completed') { completed += 1; } } } return `${completed}/${total} completed`; }, [items]); return ( {title} {score} {toggleHint ? ` (${toggleHint})` : ''} ); }; const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({ items, }) => ( {items.map((item, index) => ( ))} ); export const Checklist: React.FC = ({ title, items, isExpanded, toggleHint, }) => { const inProgress: ChecklistItemData | null = useMemo( () => items.find((item) => item.status === 'in_progress') || null, [items], ); const hasActiveItems = useMemo( () => items.some( (item) => item.status === 'pending' || item.status === 'in_progress', ), [items], ); if (items.length === 0 || (!isExpanded && !hasActiveItems)) { return null; } return ( {isExpanded ? ( ) : ( {inProgress && ( )} )} ); }; ================================================ FILE: packages/cli/src/ui/components/ChecklistItem.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; import { Box } from 'ink'; describe('', () => { it.each([ { status: 'pending', label: 'Do this' }, { status: 'in_progress', label: 'Doing this' }, { status: 'completed', label: 'Done this' }, { status: 'cancelled', label: 'Skipped this' }, { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { const { lastFrame, waitUntilReady } = render(); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('truncates long text when wrap="truncate"', async () => { const item: ChecklistItemData = { status: 'in_progress', label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('wraps long text by default', async () => { const item: ChecklistItemData = { status: 'in_progress', label: 'This is a very long text that should wrap because the default behavior is wrapping', }; const { lastFrame, waitUntilReady } = render( , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); ================================================ FILE: packages/cli/src/ui/components/ChecklistItem.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { checkExhaustive } from '@google/gemini-cli-core'; export type ChecklistStatus = | 'pending' | 'in_progress' | 'completed' | 'cancelled' | 'blocked'; export interface ChecklistItemData { status: ChecklistStatus; label: string; } const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ status, }) => { switch (status) { case 'completed': return ( ); case 'in_progress': return ( » ); case 'pending': return ( ); case 'cancelled': return ( ); case 'blocked': return ( ); default: checkExhaustive(status); } }; export interface ChecklistItemProps { item: ChecklistItemData; wrap?: 'truncate'; role?: 'listitem'; } export const ChecklistItem: React.FC = ({ item, wrap, role: ariaRole, }) => { const textColor = (() => { switch (item.status) { case 'in_progress': return theme.text.accent; case 'completed': case 'cancelled': case 'blocked': return theme.text.secondary; case 'pending': return theme.text.primary; default: checkExhaustive(item.status); } })(); const strikethrough = item.status === 'cancelled'; return ( {item.label} ); }; ================================================ FILE: packages/cli/src/ui/components/CliSpinner.test.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { CliSpinner } from './CliSpinner.js'; import { debugState } from '../debug.js'; import { describe, it, expect, beforeEach } from 'vitest'; describe('', () => { beforeEach(() => { debugState.debugNumAnimatedComponents = 0; }); it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); }); it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { settings }, ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/CliSpinner.tsx ================================================ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import Spinner from 'ink-spinner'; import { type ComponentProps, useEffect } from 'react'; import { debugState } from '../debug.js'; import { useSettings } from '../contexts/SettingsContext.js'; export type SpinnerProps = ComponentProps; export const CliSpinner = (props: SpinnerProps) => { const settings = useSettings(); const shouldShow = settings.merged.ui?.showSpinner !== false; useEffect(() => { if (shouldShow) { debugState.debugNumAnimatedComponents++; return () => { debugState.debugNumAnimatedComponents--; }; } return undefined; }, [shouldShow]); if (!shouldShow) { return null; } return ; }; ================================================ FILE: packages/cli/src/ui/components/ColorsDisplay.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../test-utils/render.js'; import { ColorsDisplay } from './ColorsDisplay.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { themeManager } from '../themes/theme-manager.js'; import type { Theme, ColorsTheme } from '../themes/theme.js'; import type { SemanticColors } from '../themes/semantic-tokens.js'; describe('ColorsDisplay', () => { beforeEach(() => { vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({ text: { primary: '#ffffff', secondary: '#cccccc', link: '#0000ff', accent: '#ff00ff', response: '#ffffff', }, background: { primary: '#000000', message: '#111111', input: '#222222', focus: '#333333', diff: { added: '#003300', removed: '#330000', }, }, border: { default: '#555555', }, ui: { comment: '#666666', symbol: '#cccccc', active: '#0000ff', dark: '#333333', focus: '#0000ff', gradient: undefined, }, status: { error: '#ff0000', success: '#00ff00', warning: '#ffff00', }, }); vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({ name: 'Test Theme', type: 'dark', colors: {} as unknown as ColorsTheme, semanticColors: { text: { primary: '#ffffff', secondary: '#cccccc', link: '#0000ff', accent: '#ff00ff', response: '#ffffff', }, background: { primary: '#000000', message: '#111111', input: '#222222', diff: { added: '#003300', removed: '#330000', }, }, border: { default: '#555555', }, ui: { comment: '#666666', symbol: '#cccccc', active: '#0000ff', dark: '#333333', focus: '#0000ff', gradient: undefined, }, status: { error: '#ff0000', success: '#00ff00', warning: '#ffff00', }, } as unknown as SemanticColors, } as unknown as Theme); }); afterEach(() => { vi.restoreAllMocks(); }); it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); // Check for title and description expect(output).toContain('How do colors get applied?'); expect(output).toContain('Hex:'); // Check for some color names and values expect(output).toContain('text.primary'); expect(output).toContain('#ffffff'); expect(output).toContain('background.diff.added'); expect(output).toContain('#003300'); expect(output).toContain('border.default'); expect(output).toContain('#555555'); unmount(); }); }); ================================================ FILE: packages/cli/src/ui/components/ColorsDisplay.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; import { theme } from '../semantic-colors.js'; import type { Theme } from '../themes/theme.js'; interface StandardColorRow { type: 'standard'; name: string; value: string; } interface GradientColorRow { type: 'gradient'; name: string; value: string[]; } interface BackgroundColorRow { type: 'background'; name: string; value: string; } type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow; const VALUE_COLUMN_WIDTH = 10; const COLOR_DESCRIPTIONS: Record = { 'text.primary': 'Primary text color (uses terminal default if blank)', 'text.secondary': 'Secondary/dimmed text color', 'text.link': 'Hyperlink and highlighting color', 'text.accent': 'Accent color for emphasis', 'text.response': 'Color for model response text (uses terminal default if blank)', 'background.primary': 'Main terminal background color', 'background.message': 'Subtle background for message blocks', 'background.input': 'Background for the input prompt', 'background.focus': 'Background highlight for selected/focused items', 'background.diff.added': 'Background for added lines in diffs', 'background.diff.removed': 'Background for removed lines in diffs', 'border.default': 'Standard border color', 'ui.comment': 'Color for code comments and metadata', 'ui.symbol': 'Color for technical symbols and UI icons', 'ui.active': 'Border color for active or running elements', 'ui.dark': 'Deeply dimmed color for subtle UI elements', 'ui.focus': 'Color for focused elements (e.g. selected menu items, focused borders)', 'status.error': 'Color for error messages and critical status', 'status.success': 'Color for success messages and positive status', 'status.warning': 'Color for warnings and cautionary status', }; interface ColorsDisplayProps { activeTheme: Theme; } /** * Determines a contrasting text color (black or white) based on the background color's luminance. */ function getContrastingTextColor(hex: string): string { if (!hex || !hex.startsWith('#') || hex.length < 7) { // Fallback for invalid hex codes or named colors return theme.text.primary; } const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); // Using YIQ formula to determine luminance const yiq = (r * 299 + g * 587 + b * 114) / 1000; return yiq >= 128 ? '#000000' : '#FFFFFF'; } export const ColorsDisplay: React.FC = ({ activeTheme, }) => { const semanticColors = activeTheme.semanticColors; const backgroundRows: BackgroundColorRow[] = []; const standardRows: StandardColorRow[] = []; let gradientRow: GradientColorRow | null = null; if (semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0) { gradientRow = { type: 'gradient', name: 'ui.gradient', value: semanticColors.ui.gradient, }; } /** * Recursively flattens the semanticColors object. */ const flattenColors = (obj: object, path: string = '') => { for (const [key, value] of Object.entries(obj)) { if (value === undefined || value === null) continue; const newPath = path ? `${path}.${key}` : key; if (key === 'gradient' && Array.isArray(value)) { // Gradient handled separately continue; } if (typeof value === 'object' && !Array.isArray(value)) { flattenColors(value, newPath); } else if (typeof value === 'string') { if (newPath.startsWith('background.')) { backgroundRows.push({ type: 'background', name: newPath, value, }); } else { standardRows.push({ type: 'standard', name: newPath, value, }); } } } }; flattenColors(semanticColors); // Final order: Backgrounds first, then Standards, then Gradient const allRows: ColorRow[] = [ ...backgroundRows, ...standardRows, ...(gradientRow ? [gradientRow] : []), ]; return ( DEVELOPER TOOLS (Not visible to users) How do colors get applied? Hex: Rendered exactly by modern terminals. Not overridden by app themes. Blank: Uses your terminal's default foreground/background. Compatibility: On older terminals, hex is approximated to the nearest ANSI color. ANSI Names: 'red', 'green', etc. are mapped to your terminal app's palette. {/* Header */} Value Name {/* All Rows */} {allRows.map((row) => { if (row.type === 'standard') return renderStandardRow(row); if (row.type === 'gradient') return renderGradientRow(row); if (row.type === 'background') return renderBackgroundRow(row); return null; })} ); }; function renderStandardRow({ name, value }: StandardColorRow) { const isHex = value.startsWith('#'); const displayColor = isHex ? value : theme.text.primary; const description = COLOR_DESCRIPTIONS[name] || ''; return ( {value || '(blank)'} {name} {description} ); } function renderGradientRow({ name, value }: GradientColorRow) { const description = COLOR_DESCRIPTIONS[name] || ''; return ( {value.map((c, i) => ( {c} ))} {name} {description} ); } function renderBackgroundRow({ name, value }: BackgroundColorRow) { const description = COLOR_DESCRIPTIONS[name] || ''; return ( {value || 'default'} {name} {description} ); } ================================================ FILE: packages/cli/src/ui/components/Composer.test.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { render } from '../../test-utils/render.js'; import { act, useEffect } from 'react'; import { Box, Text } from 'ink'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { UIActionsContext, type UIActions, } from '../contexts/UIActionsContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import { createMockSettings } from '../../test-utils/settings.js'; // Mock VimModeContext hook vi.mock('../contexts/VimModeContext.js', () => ({ useVimMode: vi.fn(() => ({ vimEnabled: false, vimMode: 'INSERT', })), })); import { ApprovalMode, tokenLimit, CoreToolCallStatus, } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; import { TransientMessageType } from '../../utils/events.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; import type { TextBuffer } from './shared/text-buffer.js'; const composerTestControls = vi.hoisted(() => ({ suggestionsVisible: false, isAlternateBuffer: false, })); // Mock child components vi.mock('./LoadingIndicator.js', () => ({ LoadingIndicator: ({ thought, thoughtLabel, }: { thought?: { subject?: string } | string; thoughtLabel?: string; }) => { const fallbackText = typeof thought === 'string' ? thought : thought?.subject; const text = thoughtLabel ?? fallbackText; return LoadingIndicator{text ? `: ${text}` : ''}; }, })); vi.mock('./StatusDisplay.js', () => ({ StatusDisplay: () => StatusDisplay, })); vi.mock('./ToastDisplay.js', () => ({ ToastDisplay: () => ToastDisplay, shouldShowToast: (uiState: UIState) => uiState.ctrlCPressedOnce || Boolean(uiState.transientMessage) || uiState.ctrlDPressedOnce || (uiState.showEscapePrompt && (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || Boolean(uiState.queueErrorMessage), })); vi.mock('./ContextSummaryDisplay.js', () => ({ ContextSummaryDisplay: () => ContextSummaryDisplay, })); vi.mock('./HookStatusDisplay.js', () => ({ HookStatusDisplay: () => HookStatusDisplay, })); vi.mock('./ApprovalModeIndicator.js', () => ({ ApprovalModeIndicator: () => ApprovalModeIndicator, })); vi.mock('./ShellModeIndicator.js', () => ({ ShellModeIndicator: () => ShellModeIndicator, })); vi.mock('./ShortcutsHint.js', () => ({ ShortcutsHint: () => ShortcutsHint, })); vi.mock('./ShortcutsHelp.js', () => ({ ShortcutsHelp: () => ShortcutsHelp, })); vi.mock('./DetailedMessagesDisplay.js', () => ({ DetailedMessagesDisplay: () => DetailedMessagesDisplay, })); vi.mock('./InputPrompt.js', () => ({ InputPrompt: ({ placeholder, onSuggestionsVisibilityChange, }: { placeholder?: string; onSuggestionsVisibilityChange?: (visible: boolean) => void; }) => { useEffect(() => { onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible); }, [onSuggestionsVisibilityChange]); return InputPrompt: {placeholder}; }, calculatePromptWidths: vi.fn(() => ({ inputWidth: 80, suggestionsWidth: 40, containerWidth: 84, })), })); vi.mock('../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: () => composerTestControls.isAlternateBuffer, })); vi.mock('./Footer.js', () => ({ Footer: () => Footer, })); vi.mock('./ShowMoreLines.js', () => ({ ShowMoreLines: () => ShowMoreLines, })); vi.mock('./QueuedMessageDisplay.js', () => ({ QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => { if (messageQueue.length === 0) { return null; } return ( <> {messageQueue.map((message, index) => ( {message} ))} ); }, })); // Mock contexts vi.mock('../contexts/OverflowContext.js', () => ({ OverflowProvider: ({ children }: { children: React.ReactNode }) => children, })); // Create mock context providers const createMockUIState = (overrides: Partial = {}): UIState => ({ streamingState: StreamingState.Idle, isConfigInitialized: true, contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], showErrorDetails: false, constrainHeight: false, isInputActive: true, buffer: { text: '' }, inputWidth: 80, suggestionsWidth: 40, userMessages: [], slashCommands: [], commandContext: null, shellModeActive: false, isFocused: true, thought: '', currentLoadingPhrase: '', elapsedTime: 0, ctrlCPressedOnce: false, ctrlDPressedOnce: false, showEscapePrompt: false, shortcutsHelpVisible: false, cleanUiDetailsVisible: true, ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, history: [], sessionStats: { sessionId: 'test-session', sessionStartTime: new Date(), // eslint-disable-next-line @typescript-eslint/no-explicit-any metrics: {} as any, lastPromptTokenCount: 0, promptCount: 0, }, branchName: 'main', debugMessage: '', corgiMode: false, errorCount: 0, nightly: false, isTrustedFolder: true, activeHooks: [], isBackgroundShellVisible: false, embeddedShellFocused: false, quota: { userTier: undefined, stats: undefined, proQuotaRequest: null, validationRequest: null, }, ...overrides, }) as UIState; const createMockUIActions = (): UIActions => ({ handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), setShellModeActive: vi.fn(), setCleanUiDetailsVisible: vi.fn(), toggleCleanUiDetailsVisible: vi.fn(), revealCleanUiDetailsTemporarily: vi.fn(), onEscapePromptChange: vi.fn(), vimHandleInput: vi.fn(), setShortcutsHelpVisible: vi.fn(), }) as Partial as UIActions; const createMockConfig = (overrides = {}): Config => ({ getModel: vi.fn(() => 'gemini-1.5-pro'), getTargetDir: vi.fn(() => '/test/dir'), getDebugMode: vi.fn(() => false), getAccessibility: vi.fn(() => ({})), getMcpServers: vi.fn(() => ({})), isPlanEnabled: vi.fn(() => true), getToolRegistry: () => ({ getTool: vi.fn(), }), getSkillManager: () => ({ getSkills: () => [], getDisplayableSkills: () => [], }), getMcpClientManager: () => ({ getMcpServers: () => ({}), getBlockedMcpServers: () => [], }), ...overrides, }) as unknown as Config; const renderComposer = async ( uiState: UIState, settings = createMockSettings(), config = createMockConfig(), uiActions = createMockUIActions(), ) => { const result = render( , ); await result.waitUntilReady(); // Wait for shortcuts hint debounce if using fake timers if (vi.isFakeTimers()) { await act(async () => { await vi.advanceTimersByTimeAsync(250); }); } return result; }; describe('Composer', () => { beforeEach(() => { vi.useFakeTimers(); composerTestControls.suggestionsVisible = false; composerTestControls.isAlternateBuffer = false; }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('Footer Display Settings', () => { it('renders Footer by default when hideFooter is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { hideFooter: false } }); const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).toContain('Footer'); }); it('does NOT render Footer when hideFooter is true', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { hideFooter: true } }); const { lastFrame } = await renderComposer(uiState, settings); // Check for content that only appears IN the Footer component itself expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses }); it('passes correct props to Footer including vim mode when enabled', async () => { const uiState = createMockUIState({ branchName: 'feature-branch', corgiMode: true, errorCount: 2, sessionStats: { sessionId: 'test-session', sessionStartTime: new Date(), metrics: { models: {}, tools: {}, files: {}, } as SessionMetrics, lastPromptTokenCount: 150, promptCount: 5, }, }); const config = createMockConfig({ getModel: vi.fn(() => 'gemini-1.5-flash'), getTargetDir: vi.fn(() => '/project/path'), getDebugMode: vi.fn(() => true), }); const settings = createMockSettings({ ui: { hideFooter: false, showMemoryUsage: true, }, }); // Mock vim mode for this test const { useVimMode } = await import('../contexts/VimModeContext.js'); vi.mocked(useVimMode).mockReturnValueOnce({ vimEnabled: true, vimMode: 'INSERT', toggleVimEnabled: vi.fn(), setVimMode: vi.fn(), } as unknown as ReturnType); const { lastFrame } = await renderComposer(uiState, settings, config); expect(lastFrame()).toContain('Footer'); // Footer should be rendered with all the state passed through }); }); describe('Loading Indicator', () => { it('renders LoadingIndicator with thought when streaming', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Processing', description: 'Processing your request...', }, currentLoadingPhrase: 'Analyzing', elapsedTime: 1500, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Processing'); }); it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Thinking about code', description: 'Full text is already in history', }, }); const settings = createMockSettings({ ui: { inlineThinkingMode: 'full' }, }); const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Thinking...'); }); it('hides shortcuts hint while loading', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, elapsedTime: 1, cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('ShortcutsHint'); }); it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ ui: { loadingPhrases: 'off' }, }); const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).toContain('LoadingIndicator: Hidden'); }); it('does not render LoadingIndicator when waiting for confirmation', async () => { const uiState = createMockUIState({ streamingState: StreamingState.WaitingForConfirmation, thought: { subject: 'Confirmation', description: 'Should not show during confirmation', }, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); }); it('does not render LoadingIndicator when a tool confirmation is pending', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, pendingHistoryItems: [ { type: 'tool_group', tools: [ { callId: 'call-1', name: 'edit', description: 'edit file', status: CoreToolCallStatus.AwaitingApproval, resultDisplay: undefined, confirmationDetails: undefined, }, ], }, ], }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); expect(output).not.toContain('esc to cancel'); }); it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, isBackgroundShellVisible: true, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); }); it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Thinking', description: '', }, showApprovalModeIndicator: ApprovalMode.PLAN, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Thinking'); expect(output).toContain('ApprovalModeIndicator'); }); it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, isBackgroundShellVisible: false, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); }); }); describe('Message Queue Display', () => { it('displays queued messages when present', async () => { const uiState = createMockUIState({ messageQueue: [ 'First queued message', 'Second queued message', 'Third queued message', ], }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('First queued message'); expect(output).toContain('Second queued message'); expect(output).toContain('Third queued message'); }); it('renders QueuedMessageDisplay with empty message queue', async () => { const uiState = createMockUIState({ messageQueue: [], }); const { lastFrame } = await renderComposer(uiState); // The component should render but return null for empty queue // This test verifies that the component receives the correct prop const output = lastFrame(); expect(output).toContain('InputPrompt'); // Verify basic Composer rendering }); }); describe('Context and Status Display', () => { it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { const uiState = createMockUIState({ ctrlCPressedOnce: false, ctrlDPressedOnce: false, showEscapePrompt: false, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('StatusDisplay'); expect(output).toContain('ApprovalModeIndicator'); expect(output).not.toContain('ToastDisplay'); }); it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { const uiState = createMockUIState({ ctrlCPressedOnce: true, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); expect(output).not.toContain('ApprovalModeIndicator'); expect(output).toContain('StatusDisplay'); }); it('shows ToastDisplay for other toast types', async () => { const uiState = createMockUIState({ transientMessage: { text: 'Warning', type: TransientMessageType.Warning, }, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); expect(output).not.toContain('ApprovalModeIndicator'); }); }); describe('Input and Indicators', () => { it('hides non-essential UI details in clean mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ShortcutsHint'); expect(output).toContain('InputPrompt'); expect(output).not.toContain('Footer'); expect(output).not.toContain('ApprovalModeIndicator'); expect(output).not.toContain('ContextSummaryDisplay'); }); it('renders InputPrompt when input is active', async () => { const uiState = createMockUIState({ isInputActive: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('InputPrompt'); }); it('does not render InputPrompt when input is inactive', async () => { const uiState = createMockUIState({ isInputActive: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('InputPrompt'); }); it.each([ [ApprovalMode.DEFAULT], [ApprovalMode.AUTO_EDIT], [ApprovalMode.PLAN], [ApprovalMode.YOLO], ])( 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive', async (mode) => { const uiState = createMockUIState({ showApprovalModeIndicator: mode, shellModeActive: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); }, ); it('shows ShellModeIndicator when shell mode is active', async () => { const uiState = createMockUIState({ shellModeActive: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); it('shows RawMarkdownIndicator when renderMarkdown is false', async () => { const uiState = createMockUIState({ renderMarkdown: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('raw markdown mode'); }); it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => { const uiState = createMockUIState({ renderMarkdown: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('raw markdown mode'); }); it.each([ [ApprovalMode.YOLO, 'YOLO'], [ApprovalMode.PLAN, 'plan'], [ApprovalMode.AUTO_EDIT, 'auto edit'], ])( 'shows minimal mode badge "%s" when clean UI details are hidden', async (mode, label) => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showApprovalModeIndicator: mode, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain(label); }, ); it('hides minimal mode badge while loading in clean mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, elapsedTime: 1, showApprovalModeIndicator: ApprovalMode.PLAN, }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('plan'); expect(output).not.toContain('ShortcutsHint'); }); it('hides minimal mode badge while action-required state is active', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showApprovalModeIndicator: ApprovalMode.PLAN, customDialog: ( Prompt ), }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('plan'); expect(output).not.toContain('ShortcutsHint'); }); it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showEscapePrompt: true, history: [{ id: 1, type: 'user', text: 'msg' }], }); const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); expect(output).not.toContain('ContextSummaryDisplay'); }); it('shows context usage bleed-through when over 60%', async () => { const model = 'gemini-2.5-pro'; const uiState = createMockUIState({ cleanUiDetailsVisible: false, currentModel: model, sessionStats: { sessionId: 'test-session', sessionStartTime: new Date(), // eslint-disable-next-line @typescript-eslint/no-explicit-any metrics: {} as any, lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7), promptCount: 0, }, }); const settings = createMockSettings({ ui: { footer: { hideContextPercentage: false }, }, }); const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).toContain('%'); }); }); describe('Error Details Display', () => { it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { const uiState = createMockUIState({ showErrorDetails: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('DetailedMessagesDisplay'); expect(lastFrame()).toContain('ShowMoreLines'); }); it('does not show error details when showErrorDetails is false', async () => { const uiState = createMockUIState({ showErrorDetails: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); }); }); describe('Vim Mode Placeholders', () => { it('shows correct placeholder in INSERT mode', async () => { const uiState = createMockUIState({ isInputActive: true }); const { useVimMode } = await import('../contexts/VimModeContext.js'); vi.mocked(useVimMode).mockReturnValue({ vimEnabled: true, vimMode: 'INSERT', toggleVimEnabled: vi.fn(), setVimMode: vi.fn(), }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain( "InputPrompt: Press 'Esc' for NORMAL mode.", ); }); it('shows correct placeholder in NORMAL mode', async () => { const uiState = createMockUIState({ isInputActive: true }); const { useVimMode } = await import('../contexts/VimModeContext.js'); vi.mocked(useVimMode).mockReturnValue({ vimEnabled: true, vimMode: 'NORMAL', toggleVimEnabled: vi.fn(), setVimMode: vi.fn(), }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain( "InputPrompt: Press 'i' for INSERT mode.", ); }); }); describe('Shortcuts Hint', () => { it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { const { lastFrame } = await renderComposer( createMockUIState({ buffer: { text: '' } as unknown as TextBuffer, cleanUiDetailsVisible: false, }), ); expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); }); it('hides shortcuts hint when text is typed in buffer', async () => { const uiState = createMockUIState({ buffer: { text: 'hello' } as unknown as TextBuffer, cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); it('hides shortcuts hint when showShortcutsHint setting is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { showShortcutsHint: false, }, }); const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).not.toContain('ShortcutsHint'); }); it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => { const uiState = createMockUIState({ customDialog: ( Test Dialog Test Content ), }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); it('keeps shortcuts hint visible when no action is required', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); it('shows shortcuts hint when full UI details are visible', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); it('hides shortcuts hint while loading when full UI details are visible', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: true, streamingState: StreamingState.Responding, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, elapsedTime: 1, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); it('shows shortcuts help in minimal mode when toggled on', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, shortcutsHelpVisible: true, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); }); it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => { composerTestControls.isAlternateBuffer = true; composerTestControls.suggestionsVisible = true; const uiState = createMockUIState({ cleanUiDetailsVisible: false, showApprovalModeIndicator: ApprovalMode.PLAN, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); expect(lastFrame()).not.toContain('plan'); }); it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => { composerTestControls.isAlternateBuffer = true; composerTestControls.suggestionsVisible = true; const uiState = createMockUIState({ cleanUiDetailsVisible: true, showApprovalModeIndicator: ApprovalMode.YOLO, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ApprovalModeIndicator'); }); it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => { composerTestControls.isAlternateBuffer = false; composerTestControls.suggestionsVisible = true; const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); }); describe('Shortcuts Help', () => { it('shows shortcuts help in passive state', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, streamingState: StreamingState.Idle, }); const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); unmount(); }); it('hides shortcuts help while streaming', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, streamingState: StreamingState.Responding, }); const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); unmount(); }); it('hides shortcuts help when action is required', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, customDialog: ( Dialog content ), }); const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); unmount(); }); }); describe('Snapshots', () => { it('matches snapshot in idle state', async () => { const uiState = createMockUIState(); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); it('matches snapshot while streaming', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Thinking', description: 'Thinking about the meaning of life...', }, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); it('matches snapshot in narrow view', async () => { const uiState = createMockUIState({ terminalWidth: 40, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); it('matches snapshot in minimal UI mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); it('matches snapshot in minimal UI mode while loading', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, elapsedTime: 1000, }); const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); }); }); ================================================ FILE: packages/cli/src/ui/components/Composer.tsx ================================================ /** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useState, useEffect, useMemo } from 'react'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { ApprovalMode, checkExhaustive, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { ShortcutsHint } from './ShortcutsHint.js'; import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { HorizontalLine } from './shared/HorizontalLine.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; import { isContextUsageHigh } from '../utils/contextUsage.js'; import { theme } from '../semantic-colors.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); const settings = useSettings(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const uiState = useUIState(); const uiActions = useUIActions(); const { vimEnabled, vimMode } = useVimMode(); const inlineThinkingMode = getInlineThinkingMode(settings); const terminalWidth = uiState.terminalWidth; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const [suggestionsVisible, setSuggestionsVisible] = useState(false); const isAlternateBuffer = useAlternateBuffer(); const { showApprovalModeIndicator } = uiState; const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; const hasPendingToolConfirmation = useMemo( () => (uiState.pendingHistoryItems ?? []) .filter( (item): item is HistoryItemToolGroup => item.type === 'tool_group', ) .some((item) => item.tools.some( (tool) => tool.status === CoreToolCallStatus.AwaitingApproval, ), ), [uiState.pendingHistoryItems], ); const hasPendingActionRequired = hasPendingToolConfirmation || Boolean(uiState.commandConfirmationRequest) || Boolean(uiState.authConsentRequest) || (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || Boolean(uiState.loopDetectionConfirmationRequest) || Boolean(uiState.quota.proQuotaRequest) || Boolean(uiState.quota.validationRequest) || Boolean(uiState.customDialog); const isPassiveShortcutsHelpState = uiState.isInputActive && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; const { setShortcutsHelpVisible } = uiActions; useEffect(() => { if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) { setShortcutsHelpVisible(false); } }, [ uiState.shortcutsHelpVisible, isPassiveShortcutsHelpState, setShortcutsHelpVisible, ]); const showShortcutsHelp = uiState.shortcutsHelpVisible && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; const hasToast = shouldShowToast(uiState); const showLoadingIndicator = (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && uiState.streamingState === StreamingState.Responding && !hasPendingActionRequired; const hideUiDetailsForSuggestions = suggestionsVisible && suggestionsPosition === 'above'; const showApprovalIndicator = !uiState.shellModeActive && !hideUiDetailsForSuggestions; const showRawMarkdownIndicator = !uiState.renderMarkdown; let modeBleedThrough: { text: string; color: string } | null = null; switch (showApprovalModeIndicator) { case ApprovalMode.YOLO: modeBleedThrough = { text: 'YOLO', color: theme.status.error }; break; case ApprovalMode.PLAN: modeBleedThrough = { text: 'plan', color: theme.status.success }; break; case ApprovalMode.AUTO_EDIT: modeBleedThrough = { text: 'auto edit', color: theme.status.warning }; break; case ApprovalMode.DEFAULT: modeBleedThrough = null; break; default: checkExhaustive(showApprovalModeIndicator); modeBleedThrough = null; break; } const hideMinimalModeHintWhileBusy = !showUiDetails && (showLoadingIndicator || hasPendingActionRequired); const minimalModeBleedThrough = hideMinimalModeHintWhileBusy ? null : modeBleedThrough; const hasMinimalStatusBleedThrough = shouldShowToast(uiState); const showMinimalContextBleedThrough = !settings.merged.ui.footer.hideContextPercentage && isContextUsageHigh( uiState.sessionStats.lastPromptTokenCount, typeof uiState.currentModel === 'string' ? uiState.currentModel : undefined, ); const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; const isModelIdle = uiState.streamingState === StreamingState.Idle; const isBufferEmpty = uiState.buffer.text.length === 0; const canShowShortcutsHint = isModelIdle && isBufferEmpty && !hasPendingActionRequired; const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = useState(canShowShortcutsHint); useEffect(() => { if (!canShowShortcutsHint) { setShowShortcutsHintDebounced(false); return; } const timeout = setTimeout(() => { setShowShortcutsHintDebounced(true); }, 200); return () => clearTimeout(timeout); }, [canShowShortcutsHint]); const shouldReserveSpaceForShortcutsHint = settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; const showShortcutsHint = shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; const showMinimalBleedThroughRow = !showUiDetails && (showMinimalModeBleedThrough || hasMinimalStatusBleedThrough || showMinimalContextBleedThrough); const showMinimalMetaRow = !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || shouldReserveSpaceForShortcutsHint); return ( {(!uiState.slashCommands || !uiState.isConfigInitialized || uiState.isResuming) && ( )} {showUiDetails && ( )} {showUiDetails && } {showUiDetails && showLoadingIndicator && ( )} {showUiDetails && showShortcutsHint && } {showMinimalMetaRow && ( {showMinimalInlineLoading && ( )} {showMinimalModeBleedThrough && minimalModeBleedThrough && ( ● {minimalModeBleedThrough.text} )} {hasMinimalStatusBleedThrough && ( )} {(showMinimalContextBleedThrough || shouldReserveSpaceForShortcutsHint) && ( {showMinimalContextBleedThrough && ( )} {showShortcutsHint && } )} )} {showShortcutsHelp && } {showUiDetails && } {showUiDetails && ( {hasToast ? ( ) : ( {showApprovalIndicator && ( )} {!showLoadingIndicator && ( <> {uiState.shellModeActive && ( )} {showRawMarkdownIndicator && ( )} )} )} {!showLoadingIndicator && ( )} )} {showUiDetails && uiState.showErrorDetails && ( )} {uiState.isInputActive && ( )} {showUiDetails && !settings.merged.ui.hideFooter && !isScreenReaderEnabled &&