From eb95c131beb27795ac38447de0a79093c6d40aa8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 23 Oct 2025 09:27:04 +0800 Subject: [PATCH] Sync upstream Gemini-CLI v0.8.2 (#838) --- .github/CODEOWNERS | 7 - .github/scripts/pr-triage.sh | 31 +- .github/workflows/ci.yml | 221 +- .../workflows/gemini-self-assign-issue.yml | 3 +- .../workflows/qwen-automated-issue-triage.yml | 21 +- .../workflows/qwen-scheduled-issue-triage.yml | 19 +- .gitignore | 14 +- .husky/pre-commit | 9 + .vscode/settings.json | 3 +- .yamllint.yml | 2 + CONTRIBUTING.md | 2 +- LICENSE | 2 +- docs/checkpointing.md | 6 +- docs/cli/authentication.md | 12 +- docs/cli/commands.md | 20 +- docs/cli/configuration-v1.md | 670 +++ docs/cli/configuration.md | 670 +-- docs/cli/index.md | 5 +- docs/cli/themes.md | 33 +- docs/core/memport.md | 4 +- docs/core/tools-api.md | 6 +- docs/extension-releasing.md | 121 + docs/extension.md | 116 +- docs/getting-started-extensions.md | 213 + docs/headless.md | 308 ++ docs/ide-companion-spec.md | 184 + docs/ide-integration.md | 2 +- docs/index.md | 2 + docs/integration-tests.md | 2 +- docs/issue-and-pr-automation.md | 10 +- docs/mermaid/context.mmd | 103 + docs/mermaid/render-path.mmd | 64 + docs/sandbox.md | 6 +- docs/sidebar.json | 68 + docs/telemetry.md | 348 +- docs/tools/mcp-server.md | 75 +- docs/tools/shell.md | 95 +- docs/troubleshooting.md | 2 +- docs/trusted-folders.md | 61 + esbuild.config.js | 37 +- eslint.config.js | 1 + hello/QWEN.md | 8 + hello/qwen-extension.json | 5 + .../context-compress-interactive.test.ts | 116 + integration-tests/ctrl-c-exit.test.ts | 129 + integration-tests/edit.test.ts | 121 + integration-tests/extensions-install.test.ts | 52 + .../file-system-interactive.test.ts | 85 + integration-tests/file-system.test.ts | 167 + integration-tests/globalSetup.ts | 4 +- integration-tests/ide-client.test.ts | 201 - integration-tests/json-output.test.ts | 89 + integration-tests/list_directory.test.ts | 2 +- .../mcp_server_cyclic_schema.test.ts | 41 +- integration-tests/mixed-input-crash.test.ts | 62 + integration-tests/read_many_files.test.ts | 10 +- integration-tests/run_shell_command.test.ts | 60 + integration-tests/save_memory.test.ts | 4 +- integration-tests/shell-service.test.ts | 156 - integration-tests/simple-mcp-server.test.ts | 19 + integration-tests/telemetry.test.ts | 26 + integration-tests/test-helper.ts | 380 +- integration-tests/utf-bom-encoding.test.ts | 141 + integration-tests/vitest.config.ts | 8 +- package-lock.json | 4469 ++++++++++++++--- package.json | 52 +- packages/cli/package.json | 19 +- packages/cli/src/commands/extensions.tsx | 4 + .../cli/src/commands/extensions/disable.ts | 35 +- .../cli/src/commands/extensions/enable.ts | 40 +- .../extensions/examples/context/QWEN.md | 8 + .../examples/context/qwen-extension.json | 4 + .../commands/fs/grep-code.toml | 6 + .../custom-commands/qwen-extension.json | 4 + .../exclude-tools/qwen-extension.json | 5 + .../extensions/examples/mcp-server/example.ts | 60 + .../examples/mcp-server/package.json | 18 + .../examples/mcp-server/qwen-extension.json | 11 + .../examples/mcp-server/tsconfig.json | 13 + .../src/commands/extensions/install.test.ts | 134 +- .../cli/src/commands/extensions/install.ts | 83 +- packages/cli/src/commands/extensions/link.ts | 55 + packages/cli/src/commands/extensions/list.ts | 2 +- .../cli/src/commands/extensions/new.test.ts | 91 + packages/cli/src/commands/extensions/new.ts | 109 + .../src/commands/extensions/uninstall.test.ts | 4 +- .../cli/src/commands/extensions/uninstall.ts | 4 +- .../cli/src/commands/extensions/update.ts | 140 +- packages/cli/src/commands/mcp/add.test.ts | 229 + packages/cli/src/commands/mcp/add.ts | 12 +- packages/cli/src/commands/mcp/list.test.ts | 11 +- packages/cli/src/commands/mcp/list.ts | 9 +- packages/cli/src/commands/mcp/remove.ts | 2 +- packages/cli/src/config/auth.test.ts | 23 +- packages/cli/src/config/auth.ts | 8 +- packages/cli/src/config/config.test.ts | 1984 ++++++-- packages/cli/src/config/config.ts | 400 +- packages/cli/src/config/extension.test.ts | 1963 +++++--- packages/cli/src/config/extension.ts | 759 ++- .../extensions/extensionEnablement.test.ts | 424 ++ .../config/extensions/extensionEnablement.ts | 239 + .../cli/src/config/extensions/github.test.ts | 429 ++ packages/cli/src/config/extensions/github.ts | 431 ++ .../cli/src/config/extensions/update.test.ts | 457 ++ packages/cli/src/config/extensions/update.ts | 182 + .../src/config/extensions/variableSchema.ts | 9 + packages/cli/src/config/keyBindings.ts | 41 +- packages/cli/src/config/settings.test.ts | 719 +-- packages/cli/src/config/settings.ts | 536 +- .../cli/src/config/settingsSchema.test.ts | 187 +- packages/cli/src/config/settingsSchema.ts | 528 +- .../cli/src/config/trustedFolders.test.ts | 270 +- packages/cli/src/config/trustedFolders.ts | 217 +- packages/cli/src/core/auth.ts | 36 + packages/cli/src/core/initializer.ts | 57 + packages/cli/src/core/theme.ts | 21 + packages/cli/src/gemini.test.tsx | 273 +- packages/cli/src/gemini.tsx | 436 +- packages/cli/src/nonInteractiveCli.test.ts | 579 ++- packages/cli/src/nonInteractiveCli.ts | 237 +- packages/cli/src/nonInteractiveCliCommands.ts | 109 + .../src/services/BuiltinCommandLoader.test.ts | 75 +- .../cli/src/services/BuiltinCommandLoader.ts | 6 +- .../src/services/FileCommandLoader.test.ts | 58 + .../cli/src/services/FileCommandLoader.ts | 8 + .../cli/src/services/McpPromptLoader.test.ts | 273 +- packages/cli/src/services/McpPromptLoader.ts | 72 +- .../prompt-processors/shellProcessor.test.ts | 8 + .../prompt-processors/shellProcessor.ts | 8 + .../cli/src/test-utils/createExtension.ts | 49 + .../cli/src/test-utils/mockCommandContext.ts | 2 + packages/cli/src/test-utils/render.tsx | 23 +- packages/cli/src/ui/App.test.tsx | 1806 +------ packages/cli/src/ui/App.tsx | 1676 +------ packages/cli/src/ui/AppContainer.test.tsx | 1237 +++++ packages/cli/src/ui/AppContainer.tsx | 1484 ++++++ packages/cli/src/ui/IdeIntegrationNudge.tsx | 16 +- .../src/ui/__snapshots__/App.test.tsx.snap | 31 - .../{components => auth}/AuthDialog.test.tsx | 92 +- .../ui/{components => auth}/AuthDialog.tsx | 12 +- .../{components => auth}/AuthInProgress.tsx | 6 +- .../useAuthCommand.ts => auth/useAuth.ts} | 84 +- .../cli/src/ui/commands/aboutCommand.test.ts | 22 +- packages/cli/src/ui/commands/aboutCommand.ts | 16 +- .../cli/src/ui/commands/bugCommand.test.ts | 23 +- packages/cli/src/ui/commands/bugCommand.ts | 15 +- .../cli/src/ui/commands/chatCommand.test.ts | 297 +- packages/cli/src/ui/commands/chatCommand.ts | 113 +- .../cli/src/ui/commands/clearCommand.test.ts | 14 +- packages/cli/src/ui/commands/clearCommand.ts | 2 +- packages/cli/src/ui/commands/corgiCommand.ts | 1 + .../cli/src/ui/commands/directoryCommand.tsx | 1 + .../src/ui/commands/extensionsCommand.test.ts | 348 +- .../cli/src/ui/commands/extensionsCommand.ts | 159 +- .../cli/src/ui/commands/ideCommand.test.ts | 178 +- packages/cli/src/ui/commands/ideCommand.ts | 40 +- .../cli/src/ui/commands/mcpCommand.test.ts | 928 +--- packages/cli/src/ui/commands/mcpCommand.ts | 415 +- .../cli/src/ui/commands/memoryCommand.test.ts | 4 +- packages/cli/src/ui/commands/memoryCommand.ts | 1 + .../cli/src/ui/commands/modelCommand.test.ts | 25 +- packages/cli/src/ui/commands/modelCommand.ts | 22 +- .../ui/commands/permissionsCommand.test.ts | 35 + ...rivacyCommand.ts => permissionsCommand.ts} | 8 +- .../src/ui/commands/privacyCommand.test.ts | 38 - .../cli/src/ui/commands/summaryCommand.ts | 1 + .../cli/src/ui/commands/toolsCommand.test.ts | 29 +- packages/cli/src/ui/commands/toolsCommand.ts | 38 +- packages/cli/src/ui/commands/types.ts | 20 +- packages/cli/src/ui/components/AboutBox.tsx | 38 +- .../cli/src/ui/components/AnsiOutput.test.tsx | 106 + packages/cli/src/ui/components/AnsiOutput.tsx | 50 + packages/cli/src/ui/components/AppHeader.tsx | 33 + .../src/ui/components/AutoAcceptIndicator.tsx | 10 +- .../cli/src/ui/components/Composer.test.tsx | 434 ++ packages/cli/src/ui/components/Composer.tsx | 163 + .../src/ui/components/ConfigInitDisplay.tsx | 47 + .../src/ui/components/ConsentPrompt.test.tsx | 119 + .../cli/src/ui/components/ConsentPrompt.tsx | 51 + .../ui/components/ConsoleSummaryDisplay.tsx | 6 +- .../ui/components/ContextSummaryDisplay.tsx | 10 +- .../src/ui/components/ContextUsageDisplay.tsx | 12 +- .../cli/src/ui/components/DebugProfiler.tsx | 4 +- .../ui/components/DetailedMessagesDisplay.tsx | 19 +- .../cli/src/ui/components/DialogManager.tsx | 271 + .../ui/components/EditorSettingsDialog.tsx | 33 +- .../cli/src/ui/components/ExitWarning.tsx | 29 + .../ui/components/FolderTrustDialog.test.tsx | 65 +- .../src/ui/components/FolderTrustDialog.tsx | 46 +- .../cli/src/ui/components/Footer.test.tsx | 222 +- packages/cli/src/ui/components/Footer.tsx | 258 +- .../ui/components/GeminiRespondingSpinner.tsx | 31 +- packages/cli/src/ui/components/Header.tsx | 10 +- packages/cli/src/ui/components/Help.test.tsx | 63 + packages/cli/src/ui/components/Help.tsx | 105 +- .../ui/components/HistoryItemDisplay.test.tsx | 166 +- .../src/ui/components/HistoryItemDisplay.tsx | 173 +- .../components/IdeTrustChangeDialog.test.tsx | 91 + .../ui/components/IdeTrustChangeDialog.tsx | 47 + .../src/ui/components/InputPrompt.test.tsx | 623 ++- .../cli/src/ui/components/InputPrompt.tsx | 387 +- .../ui/components/LoadingIndicator.test.tsx | 15 + .../src/ui/components/LoadingIndicator.tsx | 10 +- .../LoopDetectionConfirmation.test.tsx | 39 + .../components/LoopDetectionConfirmation.tsx | 97 + .../cli/src/ui/components/MainContent.tsx | 73 + .../src/ui/components/MemoryUsageDisplay.tsx | 12 +- .../src/ui/components/ModelDialog.test.tsx | 227 + .../cli/src/ui/components/ModelDialog.tsx | 104 + .../components/ModelSelectionDialog.test.tsx | 246 - .../ui/components/ModelSelectionDialog.tsx | 87 - .../src/ui/components/ModelStatsDisplay.tsx | 34 +- .../ui/components/ModelSwitchDialog.test.tsx | 3 + .../src/ui/components/ModelSwitchDialog.tsx | 3 + .../cli/src/ui/components/Notifications.tsx | 62 + .../ui/components/OpenAIKeyPrompt.test.tsx | 10 +- .../cli/src/ui/components/OpenAIKeyPrompt.tsx | 197 +- .../PermissionsModifyTrustDialog.test.tsx | 199 + .../PermissionsModifyTrustDialog.tsx | 125 + .../src/ui/components/PrepareLabel.test.tsx | 123 + .../cli/src/ui/components/PrepareLabel.tsx | 112 +- .../src/ui/components/ProQuotaDialog.test.tsx | 91 + .../cli/src/ui/components/ProQuotaDialog.tsx | 54 + .../components/QueuedMessageDisplay.test.tsx | 76 + .../ui/components/QueuedMessageDisplay.tsx | 47 + .../ui/components/QuitConfirmationDialog.tsx | 4 + .../cli/src/ui/components/QuittingDisplay.tsx | 37 + .../ui/components/QwenOAuthProgress.test.tsx | 96 +- .../src/ui/components/QwenOAuthProgress.tsx | 22 +- .../src/ui/components/SettingsDialog.test.tsx | 718 ++- .../cli/src/ui/components/SettingsDialog.tsx | 206 +- .../ui/components/ShellConfirmationDialog.tsx | 21 +- .../src/ui/components/ShellInputPrompt.tsx | 57 + .../src/ui/components/ShellModeIndicator.tsx | 6 +- .../cli/src/ui/components/ShowMoreLines.tsx | 4 +- .../cli/src/ui/components/StatsDisplay.tsx | 51 +- .../src/ui/components/SuggestionsDisplay.tsx | 92 +- .../src/ui/components/ThemeDialog.test.tsx | 108 + .../cli/src/ui/components/ThemeDialog.tsx | 191 +- packages/cli/src/ui/components/Tips.tsx | 16 +- .../src/ui/components/ToolStatsDisplay.tsx | 58 +- .../src/ui/components/UpdateNotification.tsx | 6 +- .../src/ui/components/WelcomeBackDialog.tsx | 2 + .../components/WorkspaceMigrationDialog.tsx | 53 +- .../__snapshots__/Footer.test.tsx.snap | 11 + .../HistoryItemDisplay.test.tsx.snap | 137 + .../__snapshots__/InputPrompt.test.tsx.snap | 57 + .../LoadingIndicator.test.tsx.snap | 6 + .../LoopDetectionConfirmation.test.tsx.snap | 16 + .../__snapshots__/PrepareLabel.test.tsx.snap | 25 + .../SettingsDialog.test.tsx.snap | 351 ++ .../__snapshots__/ThemeDialog.test.tsx.snap | 38 + .../messages/CompressionMessage.test.tsx | 198 + .../messages/CompressionMessage.tsx | 52 +- .../ui/components/messages/DiffRenderer.tsx | 49 +- .../ui/components/messages/ErrorMessage.tsx | 6 +- .../ui/components/messages/GeminiMessage.tsx | 7 +- .../ui/components/messages/InfoMessage.tsx | 6 +- .../messages/ToolConfirmationMessage.test.tsx | 18 - .../messages/ToolConfirmationMessage.tsx | 86 +- .../messages/ToolGroupMessage.test.tsx | 42 +- .../components/messages/ToolGroupMessage.tsx | 55 +- .../components/messages/ToolMessage.test.tsx | 53 +- .../ui/components/messages/ToolMessage.tsx | 201 +- .../ui/components/messages/UserMessage.tsx | 17 +- .../components/messages/UserShellMessage.tsx | 6 +- .../ui/components/messages/WarningMessage.tsx | 32 + .../shared/BaseSelectionList.test.tsx | 534 ++ .../components/shared/BaseSelectionList.tsx | 179 + .../DescriptiveRadioButtonSelect.test.tsx | 97 + .../shared/DescriptiveRadioButtonSelect.tsx | 70 + .../components/shared/EnumSelector.test.tsx | 152 + .../src/ui/components/shared/EnumSelector.tsx | 87 + .../src/ui/components/shared/MaxSizedBox.tsx | 11 +- .../shared/RadioButtonSelect.test.tsx | 305 +- .../components/shared/RadioButtonSelect.tsx | 205 +- .../ui/components/shared/ScopeSelector.tsx | 55 + ...DescriptiveRadioButtonSelect.test.tsx.snap | 21 + .../__snapshots__/EnumSelector.test.tsx.snap | 9 + .../RadioButtonSelect.test.tsx.snap | 47 - .../ui/components/shared/text-buffer.test.ts | 221 + .../src/ui/components/shared/text-buffer.ts | 724 ++- .../subagents/create/AgentCreationWizard.tsx | 15 +- .../subagents/create/ColorSelector.tsx | 5 +- .../subagents/create/CreationSummary.tsx | 28 +- .../subagents/create/DescriptionInput.tsx | 5 +- .../create/GenerationMethodSelector.tsx | 1 + .../subagents/create/LocationSelector.tsx | 1 + .../subagents/create/TextEntryStep.tsx | 4 +- .../subagents/create/ToolSelector.tsx | 13 +- .../subagents/manage/ActionSelectionStep.tsx | 8 +- .../subagents/manage/AgentEditStep.tsx | 1 + .../subagents/manage/AgentSelectionStep.tsx | 5 +- .../subagents/manage/AgentsManagerDialog.tsx | 3 +- .../runtime/AgentExecutionDisplay.tsx | 25 +- .../components/views/ExtensionsList.test.tsx | 110 + .../ui/components/views/ExtensionsList.tsx | 67 + .../ui/components/views/McpStatus.test.tsx | 163 + .../cli/src/ui/components/views/McpStatus.tsx | 281 ++ .../ui/components/views/ToolsList.test.tsx | 62 + .../cli/src/ui/components/views/ToolsList.tsx | 52 + .../__snapshots__/McpStatus.test.tsx.snap | 166 + .../__snapshots__/ToolsList.test.tsx.snap | 32 + packages/cli/src/ui/constants.ts | 2 + packages/cli/src/ui/contexts/AppContext.tsx | 22 + .../cli/src/ui/contexts/ConfigContext.tsx | 18 + .../src/ui/contexts/KeypressContext.test.tsx | 379 +- .../cli/src/ui/contexts/KeypressContext.tsx | 391 +- .../src/ui/contexts/SessionContext.test.tsx | 82 + .../cli/src/ui/contexts/SessionContext.tsx | 141 +- .../cli/src/ui/contexts/ShellFocusContext.tsx | 11 + .../cli/src/ui/contexts/UIActionsContext.tsx | 69 + .../cli/src/ui/contexts/UIStateContext.tsx | 158 + .../src/ui/hooks/atCommandProcessor.test.ts | 38 +- .../cli/src/ui/hooks/atCommandProcessor.ts | 26 +- packages/cli/src/ui/hooks/keyToAnsi.ts | 77 + .../ui/hooks/shellCommandProcessor.test.ts | 280 +- .../cli/src/ui/hooks/shellCommandProcessor.ts | 96 +- .../ui/hooks/slashCommandProcessor.test.ts | 188 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 179 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 6 +- packages/cli/src/ui/hooks/useAtCompletion.ts | 4 +- .../ui/hooks/useAutoAcceptIndicator.test.ts | 78 +- .../src/ui/hooks/useAutoAcceptIndicator.ts | 46 +- packages/cli/src/ui/hooks/useDialogClose.ts | 10 - .../src/ui/hooks/useExtensionUpdates.test.ts | 319 ++ .../cli/src/ui/hooks/useExtensionUpdates.ts | 186 + packages/cli/src/ui/hooks/useFocus.test.ts | 42 +- packages/cli/src/ui/hooks/useFocus.ts | 14 + .../cli/src/ui/hooks/useFolderTrust.test.ts | 71 +- packages/cli/src/ui/hooks/useFolderTrust.ts | 19 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 960 +++- packages/cli/src/ui/hooks/useGeminiStream.ts | 402 +- .../cli/src/ui/hooks/useGitBranchName.test.ts | 158 +- packages/cli/src/ui/hooks/useGitBranchName.ts | 52 +- .../src/ui/hooks/useIdeTrustListener.test.ts | 226 + .../cli/src/ui/hooks/useIdeTrustListener.ts | 90 + .../src/ui/hooks/useInputHistoryStore.test.ts | 301 ++ .../cli/src/ui/hooks/useInputHistoryStore.ts | 112 + packages/cli/src/ui/hooks/useKeypress.test.ts | 1 + .../src/ui/hooks/useLoadingIndicator.test.ts | 8 +- .../cli/src/ui/hooks/useLoadingIndicator.ts | 6 +- .../cli/src/ui/hooks/useMemoryMonitor.test.ts | 71 + packages/cli/src/ui/hooks/useMemoryMonitor.ts | 41 + .../cli/src/ui/hooks/useMessageQueue.test.ts | 9 + packages/cli/src/ui/hooks/useMessageQueue.ts | 10 +- .../cli/src/ui/hooks/useModelCommand.test.ts | 42 + packages/cli/src/ui/hooks/useModelCommand.ts | 31 + .../hooks/usePermissionsModifyTrust.test.ts | 263 + .../src/ui/hooks/usePermissionsModifyTrust.ts | 128 + .../cli/src/ui/hooks/usePhraseCycler.test.ts | 59 +- packages/cli/src/ui/hooks/usePhraseCycler.ts | 27 +- .../src/ui/hooks/usePrivacySettings.test.ts | 244 - .../cli/src/ui/hooks/usePrivacySettings.ts | 150 - .../src/ui/hooks/useQuotaAndFallback.test.ts | 391 ++ .../cli/src/ui/hooks/useQuotaAndFallback.ts | 175 + .../cli/src/ui/hooks/useReactToolScheduler.ts | 54 +- .../ui/hooks/useReverseSearchCompletion.tsx | 104 +- .../cli/src/ui/hooks/useSelectionList.test.ts | 980 ++++ packages/cli/src/ui/hooks/useSelectionList.ts | 373 ++ .../src/ui/hooks/useSlashCompletion.test.ts | 577 ++- .../cli/src/ui/hooks/useSlashCompletion.ts | 610 ++- packages/cli/src/ui/hooks/useStateAndRef.ts | 4 +- packages/cli/src/ui/hooks/useThemeCommand.ts | 17 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 478 +- packages/cli/src/ui/hooks/useWelcomeBack.ts | 4 +- .../cli/src/ui/hooks/useWorkspaceMigration.ts | 3 +- packages/cli/src/ui/keyMatchers.test.ts | 19 + .../cli/src/ui/layouts/DefaultAppLayout.tsx | 41 + .../src/ui/layouts/ScreenReaderAppLayout.tsx | 39 + packages/cli/src/ui/models/availableModels.ts | 35 +- .../src/ui/noninteractive/nonInteractiveUi.ts | 31 + .../src/ui/privacy/CloudFreePrivacyNotice.tsx | 117 - .../src/ui/privacy/CloudPaidPrivacyNotice.tsx | 59 - .../src/ui/privacy/GeminiPrivacyNotice.tsx | 62 - packages/cli/src/ui/privacy/PrivacyNotice.tsx | 42 - packages/cli/src/ui/state/extensions.ts | 89 + packages/cli/src/ui/themes/ayu.ts | 2 +- packages/cli/src/ui/themes/dracula.ts | 4 +- packages/cli/src/ui/themes/github-dark.ts | 2 +- packages/cli/src/ui/themes/theme.ts | 38 +- packages/cli/src/ui/types.ts | 105 +- packages/cli/src/ui/utils/CodeColorizer.tsx | 5 +- .../src/ui/utils/InlineMarkdownRenderer.tsx | 14 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 307 +- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 26 +- packages/cli/src/ui/utils/TableRenderer.tsx | 8 +- .../MarkdownDisplay.test.tsx.snap | 136 +- packages/cli/src/ui/utils/clipboardUtils.ts | 16 +- .../cli/src/ui/utils/displayUtils.test.ts | 65 +- packages/cli/src/ui/utils/displayUtils.ts | 13 +- packages/cli/src/ui/utils/highlight.test.ts | 136 + packages/cli/src/ui/utils/highlight.ts | 103 + .../cli/src/ui/utils/kittyProtocolDetector.ts | 75 +- .../cli/src/ui/utils/platformConstants.ts | 29 +- packages/cli/src/ui/utils/textUtils.test.ts | 170 + packages/cli/src/ui/utils/textUtils.ts | 134 +- packages/cli/src/utils/commands.test.ts | 140 + packages/cli/src/utils/commands.ts | 71 + packages/cli/src/utils/commentJson.test.ts | 182 + packages/cli/src/utils/commentJson.ts | 67 + packages/cli/src/utils/deepMerge.test.ts | 163 + packages/cli/src/utils/deepMerge.ts | 90 + packages/cli/src/utils/envVarResolver.test.ts | 297 ++ packages/cli/src/utils/envVarResolver.ts | 112 + packages/cli/src/utils/errors.test.ts | 476 ++ packages/cli/src/utils/errors.ts | 150 + packages/cli/src/utils/events.ts | 1 + packages/cli/src/utils/math.ts | 15 + packages/cli/src/utils/processUtils.test.ts | 22 + packages/cli/src/utils/processUtils.ts | 20 + packages/cli/src/utils/relaunch.test.ts | 345 ++ packages/cli/src/utils/relaunch.ts | 68 + packages/cli/src/utils/sandbox.ts | 70 +- packages/cli/src/utils/settingsUtils.test.ts | 680 ++- packages/cli/src/utils/settingsUtils.ts | 92 +- .../cli/src/utils/startupWarnings.test.ts | 23 +- packages/cli/src/utils/userStartupWarnings.ts | 2 +- packages/cli/src/utils/windowTitle.test.ts | 59 + packages/cli/src/utils/windowTitle.ts | 22 + .../src/validateNonInterActiveAuth.test.ts | 266 +- .../cli/src/validateNonInterActiveAuth.ts | 56 +- .../src/zed-integration/fileSystemService.ts | 3 + .../cli/src/zed-integration/zedIntegration.ts | 67 +- packages/cli/tsconfig.json | 5 +- packages/cli/vitest.config.ts | 11 + packages/core/index.ts | 29 +- packages/core/package.json | 10 +- packages/core/src/code_assist/codeAssist.ts | 17 + .../core/src/code_assist/converter.test.ts | 11 + packages/core/src/code_assist/converter.ts | 4 +- .../oauth-credential-storage.test.ts | 217 + .../code_assist/oauth-credential-storage.ts | 130 + packages/core/src/code_assist/oauth2.test.ts | 1556 ++++-- packages/core/src/code_assist/oauth2.ts | 176 +- packages/core/src/code_assist/server.test.ts | 37 + packages/core/src/code_assist/server.ts | 40 +- packages/core/src/code_assist/types.ts | 20 +- packages/core/src/config/config.test.ts | 687 +-- packages/core/src/config/config.ts | 515 +- packages/core/src/config/constants.ts | 22 + packages/core/src/config/models.test.ts | 83 + packages/core/src/config/models.ts | 38 + packages/core/src/config/storage.ts | 40 +- .../core/__snapshots__/prompts.test.ts.snap | 30 +- packages/core/src/core/baseLlmClient.test.ts | 451 ++ packages/core/src/core/baseLlmClient.ts | 198 + packages/core/src/core/client.test.ts | 1533 +++--- packages/core/src/core/client.ts | 683 +-- .../core/src/core/contentGenerator.test.ts | 1 + packages/core/src/core/contentGenerator.ts | 54 +- .../core/src/core/coreToolScheduler.test.ts | 780 +-- packages/core/src/core/coreToolScheduler.ts | 198 +- packages/core/src/core/geminiChat.test.ts | 1561 +++--- packages/core/src/core/geminiChat.ts | 622 +-- packages/core/src/core/logger.test.ts | 80 - packages/core/src/core/logger.ts | 11 - .../core/src/core/loggingContentGenerator.ts | 41 +- .../core/nonInteractiveToolExecutor.test.ts | 118 +- packages/core/src/core/prompts.test.ts | 340 +- packages/core/src/core/prompts.ts | 185 +- packages/core/src/core/turn.test.ts | 255 +- packages/core/src/core/turn.ts | 97 +- packages/core/src/fallback/handler.test.ts | 218 + packages/core/src/fallback/handler.ts | 129 + packages/core/src/fallback/types.ts | 23 + packages/core/src/ide/constants.ts | 3 + packages/core/src/ide/detect-ide.test.ts | 111 +- packages/core/src/ide/detect-ide.ts | 117 +- packages/core/src/ide/ide-client.test.ts | 492 +- packages/core/src/ide/ide-client.ts | 458 +- packages/core/src/ide/ide-installer.test.ts | 65 +- packages/core/src/ide/ide-installer.ts | 32 +- packages/core/src/ide/ideContext.test.ts | 199 +- packages/core/src/ide/ideContext.ts | 202 +- packages/core/src/ide/process-utils.ts | 9 +- packages/core/src/ide/types.ts | 148 + packages/core/src/index.ts | 14 +- packages/core/src/mcp/oauth-provider.test.ts | 310 +- packages/core/src/mcp/oauth-provider.ts | 188 +- .../core/src/mcp/oauth-token-storage.test.ts | 606 ++- packages/core/src/mcp/oauth-token-storage.ts | 124 +- packages/core/src/mcp/oauth-utils.ts | 1 + .../src/mcp/sa-impersonation-provider.test.ts | 153 + .../core/src/mcp/sa-impersonation-provider.ts | 171 + .../token-storage/base-token-storage.test.ts | 2 +- .../mcp/token-storage/base-token-storage.ts | 2 +- .../token-storage/file-token-storage.test.ts | 323 ++ .../mcp/token-storage/file-token-storage.ts | 184 + .../hybrid-token-storage.test.ts | 274 + .../mcp/token-storage/hybrid-token-storage.ts | 97 + packages/core/src/mcp/token-storage/index.ts | 14 + .../keychain-token-storage.test.ts | 352 ++ .../token-storage/keychain-token-storage.ts | 251 + packages/core/src/mcp/token-storage/types.ts | 5 + .../core/src/output/json-formatter.test.ts | 301 ++ packages/core/src/output/json-formatter.ts | 39 + packages/core/src/output/types.ts | 24 + .../src/services/chatRecordingService.test.ts | 90 +- .../core/src/services/chatRecordingService.ts | 86 +- .../src/services/fileDiscoveryService.test.ts | 22 +- .../core/src/services/fileDiscoveryService.ts | 92 +- .../core/src/services/fileSystemService.ts | 21 + packages/core/src/services/gitService.test.ts | 38 +- packages/core/src/services/gitService.ts | 22 +- .../src/services/loopDetectionService.test.ts | 64 +- .../core/src/services/loopDetectionService.ts | 70 +- .../services/shellExecutionService.test.ts | 334 +- .../src/services/shellExecutionService.ts | 341 +- packages/core/src/subagents/subagent.test.ts | 40 +- packages/core/src/subagents/subagent.ts | 23 +- packages/core/src/subagents/types.ts | 2 +- .../clearcut-logger/clearcut-logger.test.ts | 219 +- .../clearcut-logger/clearcut-logger.ts | 239 +- .../clearcut-logger/event-metadata-key.ts | 97 +- packages/core/src/telemetry/config.test.ts | 155 + packages/core/src/telemetry/config.ts | 120 + packages/core/src/telemetry/constants.ts | 25 +- packages/core/src/telemetry/index.ts | 107 +- .../src/telemetry/loggers.test.circular.ts | 6 +- packages/core/src/telemetry/loggers.test.ts | 588 ++- packages/core/src/telemetry/loggers.ts | 381 +- packages/core/src/telemetry/metrics.test.ts | 712 ++- packages/core/src/telemetry/metrics.ts | 722 ++- .../telemetry/qwen-logger/qwen-logger.test.ts | 3 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 456 +- packages/core/src/telemetry/sdk.test.ts | 44 + packages/core/src/telemetry/sdk.ts | 5 +- packages/core/src/telemetry/types.ts | 179 +- .../core/src/telemetry/uiTelemetry.test.ts | 34 +- packages/core/src/telemetry/uiTelemetry.ts | 14 +- packages/core/src/test-utils/config.ts | 2 +- packages/core/src/test-utils/index.ts | 7 + .../src/test-utils/{tools.ts => mock-tool.ts} | 123 +- .../tools/__snapshots__/shell.test.ts.snap | 4 +- packages/core/src/tools/diffOptions.test.ts | 80 +- packages/core/src/tools/diffOptions.ts | 36 +- packages/core/src/tools/edit.test.ts | 212 +- packages/core/src/tools/edit.ts | 137 +- packages/core/src/tools/glob.test.ts | 105 +- packages/core/src/tools/glob.ts | 68 +- packages/core/src/tools/ls.test.ts | 490 +- packages/core/src/tools/ls.ts | 87 +- .../core/src/tools/mcp-client-manager.test.ts | 32 +- packages/core/src/tools/mcp-client-manager.ts | 22 +- packages/core/src/tools/mcp-client.test.ts | 202 +- packages/core/src/tools/mcp-client.ts | 207 +- packages/core/src/tools/mcp-tool.test.ts | 233 +- packages/core/src/tools/mcp-tool.ts | 44 +- packages/core/src/tools/memoryTool.ts | 45 +- packages/core/src/tools/read-file.test.ts | 39 + packages/core/src/tools/read-file.ts | 14 +- .../core/src/tools/read-many-files.test.ts | 12 +- packages/core/src/tools/read-many-files.ts | 125 +- packages/core/src/tools/ripGrep.test.ts | 139 +- packages/core/src/tools/ripGrep.ts | 33 +- packages/core/src/tools/shell.test.ts | 222 +- packages/core/src/tools/shell.ts | 153 +- packages/core/src/tools/smart-edit.test.ts | 674 +++ packages/core/src/tools/smart-edit.ts | 965 ++++ packages/core/src/tools/task.ts | 2 +- packages/core/src/tools/tool-error.ts | 1 + packages/core/src/tools/tool-registry.test.ts | 20 +- packages/core/src/tools/tool-registry.ts | 10 +- packages/core/src/tools/tools.ts | 32 +- packages/core/src/tools/web-fetch.ts | 2 + packages/core/src/tools/web-search.ts | 5 +- packages/core/src/tools/write-file.test.ts | 132 +- packages/core/src/tools/write-file.ts | 63 +- packages/core/src/utils/bfsFileSearch.test.ts | 8 +- packages/core/src/utils/bfsFileSearch.ts | 23 +- packages/core/src/utils/editor.test.ts | 163 +- packages/core/src/utils/editor.ts | 79 +- packages/core/src/utils/errorParsing.test.ts | 28 +- packages/core/src/utils/errorParsing.ts | 4 +- .../core/src/utils/errorReporting.test.ts | 4 +- packages/core/src/utils/errorReporting.ts | 2 +- packages/core/src/utils/errors.ts | 10 + packages/core/src/utils/fileUtils.test.ts | 460 +- packages/core/src/utils/fileUtils.ts | 200 +- .../core/src/utils/filesearch/crawler.test.ts | 30 +- .../src/utils/filesearch/fileSearch.test.ts | 52 +- .../core/src/utils/filesearch/fileSearch.ts | 2 +- .../core/src/utils/filesearch/ignore.test.ts | 12 +- packages/core/src/utils/filesearch/ignore.ts | 10 +- ...egration.test.ts => flashFallback.test.ts} | 64 +- .../core/src/utils/getFolderStructure.test.ts | 10 +- packages/core/src/utils/getFolderStructure.ts | 12 +- .../core/src/utils/gitIgnoreParser.test.ts | 119 +- packages/core/src/utils/gitIgnoreParser.ts | 190 +- .../core/src/utils/llm-edit-fixer.test.ts | 322 ++ packages/core/src/utils/llm-edit-fixer.ts | 164 + .../core/src/utils/memoryDiscovery.test.ts | 108 +- packages/core/src/utils/memoryDiscovery.ts | 30 +- .../src/utils/memoryImportProcessor.test.ts | 25 + .../core/src/utils/memoryImportProcessor.ts | 39 +- .../core/src/utils/nextSpeakerChecker.test.ts | 185 +- packages/core/src/utils/nextSpeakerChecker.ts | 16 +- packages/core/src/utils/pathReader.test.ts | 2 +- packages/core/src/utils/pathReader.ts | 4 +- packages/core/src/utils/promptIdContext.ts | 9 + .../core/src/utils/qwenIgnoreParser.test.ts | 68 + packages/core/src/utils/qwenIgnoreParser.ts | 81 + packages/core/src/utils/retry.test.ts | 58 +- packages/core/src/utils/retry.ts | 36 +- .../core/src/utils/schemaValidator.test.ts | 125 + packages/core/src/utils/schemaValidator.ts | 13 +- packages/core/src/utils/shell-utils.test.ts | 9 + packages/core/src/utils/shell-utils.ts | 39 +- .../core/src/utils/subagentGenerator.test.ts | 183 +- packages/core/src/utils/subagentGenerator.ts | 16 +- .../core/src/utils/terminalSerializer.test.ts | 197 + packages/core/src/utils/terminalSerializer.ts | 476 ++ packages/core/src/utils/textUtils.test.ts | 79 + packages/core/src/utils/textUtils.ts | 21 + packages/core/src/utils/thoughtUtils.test.ts | 80 + packages/core/src/utils/thoughtUtils.ts | 54 + packages/core/tsconfig.json | 2 +- packages/core/vitest.config.ts | 6 + packages/test-utils/vitest.config.ts | 23 + packages/vscode-ide-companion/NOTICES.txt | 369 +- packages/vscode-ide-companion/development.md | 28 +- packages/vscode-ide-companion/esbuild.js | 6 + packages/vscode-ide-companion/package.json | 22 +- .../scripts/generate-notices.js | 2 + .../vscode-ide-companion/src/diff-manager.ts | 26 +- .../src/extension.test.ts | 198 +- .../vscode-ide-companion/src/extension.ts | 96 +- .../src/ide-server.test.ts | 370 +- .../vscode-ide-companion/src/ide-server.ts | 297 +- .../src/open-files-manager.ts | 6 +- packages/vscode-ide-companion/tsconfig.json | 7 - scripts/build_sandbox.js | 22 +- scripts/build_vscode_companion.js | 4 +- scripts/check-lockfile.js | 74 + scripts/clean.js | 6 +- scripts/copy_files.js | 21 + scripts/get-release-version.js | 534 +- scripts/lint.js | 205 + scripts/pre-commit.js | 22 + scripts/telemetry_utils.js | 32 +- scripts/tests/get-release-version.test.js | 281 +- scripts/tests/vitest.config.ts | 6 + scripts/version.js | 42 +- 644 files changed, 70389 insertions(+), 23709 deletions(-) delete mode 100644 .github/CODEOWNERS create mode 100755 .husky/pre-commit create mode 100644 docs/cli/configuration-v1.md create mode 100644 docs/extension-releasing.md create mode 100644 docs/getting-started-extensions.md create mode 100644 docs/headless.md create mode 100644 docs/ide-companion-spec.md create mode 100644 docs/mermaid/context.mmd create mode 100644 docs/mermaid/render-path.mmd create mode 100644 docs/sidebar.json create mode 100644 docs/trusted-folders.md create mode 100644 hello/QWEN.md create mode 100644 hello/qwen-extension.json create mode 100644 integration-tests/context-compress-interactive.test.ts create mode 100644 integration-tests/ctrl-c-exit.test.ts create mode 100644 integration-tests/extensions-install.test.ts create mode 100644 integration-tests/file-system-interactive.test.ts delete mode 100644 integration-tests/ide-client.test.ts create mode 100644 integration-tests/json-output.test.ts create mode 100644 integration-tests/mixed-input-crash.test.ts delete mode 100644 integration-tests/shell-service.test.ts create mode 100644 integration-tests/telemetry.test.ts create mode 100644 integration-tests/utf-bom-encoding.test.ts create mode 100644 packages/cli/src/commands/extensions/examples/context/QWEN.md create mode 100644 packages/cli/src/commands/extensions/examples/context/qwen-extension.json create mode 100644 packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml create mode 100644 packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json create mode 100644 packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json create mode 100644 packages/cli/src/commands/extensions/examples/mcp-server/example.ts create mode 100644 packages/cli/src/commands/extensions/examples/mcp-server/package.json create mode 100644 packages/cli/src/commands/extensions/examples/mcp-server/qwen-extension.json create mode 100644 packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json create mode 100644 packages/cli/src/commands/extensions/link.ts create mode 100644 packages/cli/src/commands/extensions/new.test.ts create mode 100644 packages/cli/src/commands/extensions/new.ts create mode 100644 packages/cli/src/config/extensions/extensionEnablement.test.ts create mode 100644 packages/cli/src/config/extensions/extensionEnablement.ts create mode 100644 packages/cli/src/config/extensions/github.test.ts create mode 100644 packages/cli/src/config/extensions/github.ts create mode 100644 packages/cli/src/config/extensions/update.test.ts create mode 100644 packages/cli/src/config/extensions/update.ts create mode 100644 packages/cli/src/core/auth.ts create mode 100644 packages/cli/src/core/initializer.ts create mode 100644 packages/cli/src/core/theme.ts create mode 100644 packages/cli/src/nonInteractiveCliCommands.ts create mode 100644 packages/cli/src/test-utils/createExtension.ts create mode 100644 packages/cli/src/ui/AppContainer.test.tsx create mode 100644 packages/cli/src/ui/AppContainer.tsx delete mode 100644 packages/cli/src/ui/__snapshots__/App.test.tsx.snap rename packages/cli/src/ui/{components => auth}/AuthDialog.test.tsx (76%) rename packages/cli/src/ui/{components => auth}/AuthDialog.tsx (93%) rename packages/cli/src/ui/{components => auth}/AuthInProgress.tsx (90%) rename packages/cli/src/ui/{hooks/useAuthCommand.ts => auth/useAuth.ts} (51%) create mode 100644 packages/cli/src/ui/commands/permissionsCommand.test.ts rename packages/cli/src/ui/commands/{privacyCommand.ts => permissionsCommand.ts} (67%) delete mode 100644 packages/cli/src/ui/commands/privacyCommand.test.ts create mode 100644 packages/cli/src/ui/components/AnsiOutput.test.tsx create mode 100644 packages/cli/src/ui/components/AnsiOutput.tsx create mode 100644 packages/cli/src/ui/components/AppHeader.tsx create mode 100644 packages/cli/src/ui/components/Composer.test.tsx create mode 100644 packages/cli/src/ui/components/Composer.tsx create mode 100644 packages/cli/src/ui/components/ConfigInitDisplay.tsx create mode 100644 packages/cli/src/ui/components/ConsentPrompt.test.tsx create mode 100644 packages/cli/src/ui/components/ConsentPrompt.tsx create mode 100644 packages/cli/src/ui/components/DialogManager.tsx create mode 100644 packages/cli/src/ui/components/ExitWarning.tsx create mode 100644 packages/cli/src/ui/components/Help.test.tsx create mode 100644 packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx create mode 100644 packages/cli/src/ui/components/IdeTrustChangeDialog.tsx create mode 100644 packages/cli/src/ui/components/LoopDetectionConfirmation.test.tsx create mode 100644 packages/cli/src/ui/components/LoopDetectionConfirmation.tsx create mode 100644 packages/cli/src/ui/components/MainContent.tsx create mode 100644 packages/cli/src/ui/components/ModelDialog.test.tsx create mode 100644 packages/cli/src/ui/components/ModelDialog.tsx delete mode 100644 packages/cli/src/ui/components/ModelSelectionDialog.test.tsx delete mode 100644 packages/cli/src/ui/components/ModelSelectionDialog.tsx create mode 100644 packages/cli/src/ui/components/Notifications.tsx create mode 100644 packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx create mode 100644 packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx create mode 100644 packages/cli/src/ui/components/PrepareLabel.test.tsx create mode 100644 packages/cli/src/ui/components/ProQuotaDialog.test.tsx create mode 100644 packages/cli/src/ui/components/ProQuotaDialog.tsx create mode 100644 packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/QueuedMessageDisplay.tsx create mode 100644 packages/cli/src/ui/components/QuittingDisplay.tsx create mode 100644 packages/cli/src/ui/components/ShellInputPrompt.tsx create mode 100644 packages/cli/src/ui/components/ThemeDialog.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/LoopDetectionConfirmation.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/CompressionMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/WarningMessage.tsx create mode 100644 packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx create mode 100644 packages/cli/src/ui/components/shared/BaseSelectionList.tsx create mode 100644 packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx create mode 100644 packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx create mode 100644 packages/cli/src/ui/components/shared/EnumSelector.test.tsx create mode 100644 packages/cli/src/ui/components/shared/EnumSelector.tsx create mode 100644 packages/cli/src/ui/components/shared/ScopeSelector.tsx create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap delete mode 100644 packages/cli/src/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap create mode 100644 packages/cli/src/ui/components/views/ExtensionsList.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionsList.tsx create mode 100644 packages/cli/src/ui/components/views/McpStatus.test.tsx create mode 100644 packages/cli/src/ui/components/views/McpStatus.tsx create mode 100644 packages/cli/src/ui/components/views/ToolsList.test.tsx create mode 100644 packages/cli/src/ui/components/views/ToolsList.tsx create mode 100644 packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap create mode 100644 packages/cli/src/ui/components/views/__snapshots__/ToolsList.test.tsx.snap create mode 100644 packages/cli/src/ui/contexts/AppContext.tsx create mode 100644 packages/cli/src/ui/contexts/ConfigContext.tsx create mode 100644 packages/cli/src/ui/contexts/ShellFocusContext.tsx create mode 100644 packages/cli/src/ui/contexts/UIActionsContext.tsx create mode 100644 packages/cli/src/ui/contexts/UIStateContext.tsx create mode 100644 packages/cli/src/ui/hooks/keyToAnsi.ts create mode 100644 packages/cli/src/ui/hooks/useExtensionUpdates.test.ts create mode 100644 packages/cli/src/ui/hooks/useExtensionUpdates.ts create mode 100644 packages/cli/src/ui/hooks/useIdeTrustListener.test.ts create mode 100644 packages/cli/src/ui/hooks/useIdeTrustListener.ts create mode 100644 packages/cli/src/ui/hooks/useInputHistoryStore.test.ts create mode 100644 packages/cli/src/ui/hooks/useInputHistoryStore.ts create mode 100644 packages/cli/src/ui/hooks/useMemoryMonitor.test.ts create mode 100644 packages/cli/src/ui/hooks/useMemoryMonitor.ts create mode 100644 packages/cli/src/ui/hooks/useModelCommand.test.ts create mode 100644 packages/cli/src/ui/hooks/useModelCommand.ts create mode 100644 packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts create mode 100644 packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts delete mode 100644 packages/cli/src/ui/hooks/usePrivacySettings.test.ts delete mode 100644 packages/cli/src/ui/hooks/usePrivacySettings.ts create mode 100644 packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts create mode 100644 packages/cli/src/ui/hooks/useQuotaAndFallback.ts create mode 100644 packages/cli/src/ui/hooks/useSelectionList.test.ts create mode 100644 packages/cli/src/ui/hooks/useSelectionList.ts create mode 100644 packages/cli/src/ui/layouts/DefaultAppLayout.tsx create mode 100644 packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx create mode 100644 packages/cli/src/ui/noninteractive/nonInteractiveUi.ts delete mode 100644 packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx delete mode 100644 packages/cli/src/ui/privacy/CloudPaidPrivacyNotice.tsx delete mode 100644 packages/cli/src/ui/privacy/GeminiPrivacyNotice.tsx delete mode 100644 packages/cli/src/ui/privacy/PrivacyNotice.tsx create mode 100644 packages/cli/src/ui/state/extensions.ts create mode 100644 packages/cli/src/ui/utils/highlight.test.ts create mode 100644 packages/cli/src/ui/utils/highlight.ts create mode 100644 packages/cli/src/ui/utils/textUtils.test.ts create mode 100644 packages/cli/src/utils/commands.test.ts create mode 100644 packages/cli/src/utils/commands.ts create mode 100644 packages/cli/src/utils/commentJson.test.ts create mode 100644 packages/cli/src/utils/commentJson.ts create mode 100644 packages/cli/src/utils/deepMerge.test.ts create mode 100644 packages/cli/src/utils/deepMerge.ts create mode 100644 packages/cli/src/utils/envVarResolver.test.ts create mode 100644 packages/cli/src/utils/envVarResolver.ts create mode 100644 packages/cli/src/utils/errors.test.ts create mode 100644 packages/cli/src/utils/math.ts create mode 100644 packages/cli/src/utils/processUtils.test.ts create mode 100644 packages/cli/src/utils/processUtils.ts create mode 100644 packages/cli/src/utils/relaunch.test.ts create mode 100644 packages/cli/src/utils/relaunch.ts create mode 100644 packages/cli/src/utils/windowTitle.test.ts create mode 100644 packages/cli/src/utils/windowTitle.ts create mode 100644 packages/core/src/code_assist/oauth-credential-storage.test.ts create mode 100644 packages/core/src/code_assist/oauth-credential-storage.ts create mode 100644 packages/core/src/config/constants.ts create mode 100644 packages/core/src/config/models.test.ts create mode 100644 packages/core/src/core/baseLlmClient.test.ts create mode 100644 packages/core/src/core/baseLlmClient.ts create mode 100644 packages/core/src/fallback/handler.test.ts create mode 100644 packages/core/src/fallback/handler.ts create mode 100644 packages/core/src/fallback/types.ts create mode 100644 packages/core/src/ide/types.ts create mode 100644 packages/core/src/mcp/sa-impersonation-provider.test.ts create mode 100644 packages/core/src/mcp/sa-impersonation-provider.ts create mode 100644 packages/core/src/mcp/token-storage/file-token-storage.test.ts create mode 100644 packages/core/src/mcp/token-storage/file-token-storage.ts create mode 100644 packages/core/src/mcp/token-storage/hybrid-token-storage.test.ts create mode 100644 packages/core/src/mcp/token-storage/hybrid-token-storage.ts create mode 100644 packages/core/src/mcp/token-storage/index.ts create mode 100644 packages/core/src/mcp/token-storage/keychain-token-storage.test.ts create mode 100644 packages/core/src/mcp/token-storage/keychain-token-storage.ts create mode 100644 packages/core/src/output/json-formatter.test.ts create mode 100644 packages/core/src/output/json-formatter.ts create mode 100644 packages/core/src/output/types.ts create mode 100644 packages/core/src/telemetry/config.test.ts create mode 100644 packages/core/src/telemetry/config.ts create mode 100644 packages/core/src/test-utils/index.ts rename packages/core/src/test-utils/{tools.ts => mock-tool.ts} (55%) create mode 100644 packages/core/src/tools/smart-edit.test.ts create mode 100644 packages/core/src/tools/smart-edit.ts rename packages/core/src/utils/{flashFallback.integration.test.ts => flashFallback.test.ts} (61%) create mode 100644 packages/core/src/utils/llm-edit-fixer.test.ts create mode 100644 packages/core/src/utils/llm-edit-fixer.ts create mode 100644 packages/core/src/utils/promptIdContext.ts create mode 100644 packages/core/src/utils/qwenIgnoreParser.test.ts create mode 100644 packages/core/src/utils/qwenIgnoreParser.ts create mode 100644 packages/core/src/utils/schemaValidator.test.ts create mode 100644 packages/core/src/utils/terminalSerializer.test.ts create mode 100644 packages/core/src/utils/terminalSerializer.ts create mode 100644 packages/core/src/utils/textUtils.test.ts create mode 100644 packages/core/src/utils/thoughtUtils.test.ts create mode 100644 packages/core/src/utils/thoughtUtils.ts create mode 100644 packages/test-utils/vitest.config.ts create mode 100644 scripts/check-lockfile.js create mode 100644 scripts/lint.js create mode 100644 scripts/pre-commit.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index bc16c551..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,7 +0,0 @@ -# By default, require reviews from the release approvers for all files. -* @google-gemini/gemini-cli-askmode-approvers - -# The following files don't need reviews from the release approvers. -# These patterns override the rule above. -**/*.md -/docs/ \ No newline at end of file diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh index a13481f6..aeab6d26 100755 --- a/.github/scripts/pr-triage.sh +++ b/.github/scripts/pr-triage.sh @@ -19,24 +19,10 @@ process_pr() { local PR_NUMBER=$1 echo "🔄 Processing PR #${PR_NUMBER}" - # Get PR body with error handling - local PR_BODY - if ! PR_BODY=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json body -q .body 2>/dev/null); then - echo " ⚠️ Could not fetch PR #${PR_NUMBER} details" - return 1 - fi - - # Look for issue references using multiple patterns - local ISSUE_NUMBER="" - - # Pattern 1: Direct reference like #123 - if [[ -z "${ISSUE_NUMBER}" ]]; then - ISSUE_NUMBER=$(echo "${PR_BODY}" | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "") - fi - - # Pattern 2: Closes/Fixes/Resolves patterns (case-insensitive) - if [[ -z "${ISSUE_NUMBER}" ]]; then - ISSUE_NUMBER=$(echo "${PR_BODY}" | grep -iE '(closes?|fixes?|resolves?) #[0-9]+' | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "") + # Get closing issue number with error handling + local ISSUE_NUMBER + if ! ISSUE_NUMBER=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json closingIssuesReferences -q '.closingIssuesReferences.nodes[0].number' 2>/dev/null); then + echo " ⚠️ Could not fetch closing issue for PR #${PR_NUMBER}" fi if [[ -z "${ISSUE_NUMBER}" ]]; then @@ -110,14 +96,7 @@ process_pr() { fi fi - if [[ -n "${LABELS_TO_REMOVE}" ]]; then - echo "➖ Removing labels: ${LABELS_TO_REMOVE}" - if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "${LABELS_TO_REMOVE}" 2>/dev/null; then - echo " ⚠️ Failed to remove some labels" - fi - fi - - if [[ -z "${LABELS_TO_ADD}" ]] && [[ -z "${LABELS_TO_REMOVE}" ]]; then + if [[ -z "${LABELS_TO_ADD}" ]]; then echo "✅ Labels already synchronized" fi echo "needs_comment=false" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 391809cc..c410b6cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,13 @@ on: - '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 }}' @@ -33,222 +40,48 @@ env: YAMLLINT_VERSION: '1.35.1' jobs: - # - # Lint: GitHub Actions - # - lint_github_actions: - name: 'Lint (GitHub Actions)' + lint: + name: 'Lint' runs-on: 'ubuntu-latest' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 with: - fetch-depth: 1 - - - name: 'Install shellcheck' # Actionlint uses shellcheck - run: |- - mkdir -p "${RUNNER_TEMP}/shellcheck" - curl -sSLo "${RUNNER_TEMP}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" - tar -xf "${RUNNER_TEMP}/.shellcheck.txz" -C "${RUNNER_TEMP}/shellcheck" --strip-components=1 - echo "${RUNNER_TEMP}/shellcheck" >> "${GITHUB_PATH}" - - - name: 'Install actionlint' - run: |- - mkdir -p "${RUNNER_TEMP}/actionlint" - curl -sSLo "${RUNNER_TEMP}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - tar -xzf "${RUNNER_TEMP}/.actionlint.tgz" -C "${RUNNER_TEMP}/actionlint" - echo "${RUNNER_TEMP}/actionlint" >> "${GITHUB_PATH}" - - # For actionlint, we specifically ignore shellcheck rules that are - # annoying or unhelpful. See the shellcheck action for a description. - - name: 'Run actionlint' - run: |- - actionlint \ - -color \ - -format '{{range $err := .}}::error file={{$err.Filepath}},line={{$err.Line}},col={{$err.Column}}::{{$err.Filepath}}@{{$err.Line}} {{$err.Message}}%0A```%0A{{replace $err.Snippet "\\n" "%0A"}}%0A```\n{{end}}' \ - -ignore 'SC2002:' \ - -ignore 'SC2016:' \ - -ignore 'SC2129:' \ - -ignore 'label ".+" is unknown' - - - name: 'Run ratchet' - uses: 'sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253' # ratchet:sethvargo/ratchet@v0.11.4 - with: - files: |- - .github/workflows/*.yml - .github/actions/**/*.yml - - # - # Lint: Javascript - # - lint_javascript: - name: 'Lint (Javascript)' - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - fetch-depth: 1 + 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' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false + run: 'npm ci' - - name: 'Run formatter check' - run: |- - npm run format - git diff --exit-code + - name: 'Check lockfile' + run: 'npm run check:lockfile' - - name: 'Run linter' - run: |- - npm run lint:ci + - name: 'Install linters' + run: 'node scripts/lint.js --setup' - - name: 'Run linter on integration tests' - run: |- - npx eslint integration-tests --max-warnings 0 + - name: 'Run ESLint' + run: 'node scripts/lint.js --eslint' - - name: 'Run formatter on integration tests' - run: |- - npx prettier --check integration-tests - git diff --exit-code + - name: 'Run actionlint' + run: 'node scripts/lint.js --actionlint' - - name: 'Build project' - run: |- - npm run build - - - name: 'Run type check' - run: |- - npm run typecheck - - # - # Lint: Shell - # - lint_shell: - name: 'Lint (Shell)' - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - fetch-depth: 1 - - - name: 'Install shellcheck' - run: |- - mkdir -p "${RUNNER_TEMP}/shellcheck" - curl -sSLo "${RUNNER_TEMP}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" - tar -xf "${RUNNER_TEMP}/.shellcheck.txz" -C "${RUNNER_TEMP}/shellcheck" --strip-components=1 - echo "${RUNNER_TEMP}/shellcheck" >> "${GITHUB_PATH}" - - - name: 'Install shellcheck problem matcher' - run: |- - cat > "${RUNNER_TEMP}/shellcheck/problem-matcher-lint-shell.json" <<"EOF" - { - "problemMatcher": [ - { - "owner": "lint_shell", - "pattern": [ - { - "regexp": "^(.*):(\\\\d+):(\\\\d+):\\\\s+(?:fatal\\\\s+)?(warning|error):\\\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - ] - } - ] - } - EOF - echo "::add-matcher::${RUNNER_TEMP}/shellcheck/problem-matcher-lint-shell.json" - - # Note that only warning and error severity show up in the github files - # page. So we replace 'style' and 'note' with 'warning' to make it show - # up. - # - # We also try and find all bash scripts even if they don't have an - # explicit extension. - # - # We explicitly ignore the following rules: - # - # - SC2002: This rule suggests using "cmd < file" instead of "cat | cmd". - # While < is more efficient, pipes are much more readable and expected. - # - # - SC2129: This rule suggests grouping multiple writes to a file in - # braces like "{ cmd1; cmd2; } >> file". This is unexpected and less - # readable. - # - # - SC2310: This is an optional warning that only appears with "set -e" - # and when a command is used as a conditional. - name: 'Run shellcheck' - run: |- - git ls-files | grep -E '^([^.]+|.*\.(sh|zsh|bash))$' | grep -v '^integration-tests/terminal-bench/ci-tasks/' | xargs file --mime-type \ - | grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \ - | xargs shellcheck \ - --check-sourced \ - --enable=all \ - --exclude=SC2002,SC2129,SC2310 \ - --severity=style \ - --format=gcc \ - --color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g' - - # - # Lint: YAML - # - lint_yaml: - name: 'Lint (YAML)' - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - fetch-depth: 1 - - - name: 'Setup Python' - uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5 - with: - python-version: '3' - - - name: 'Install yamllint' - run: |- - pip install --user "yamllint==${YAMLLINT_VERSION}" + run: 'node scripts/lint.js --shellcheck' - name: 'Run yamllint' - run: |- - git ls-files | grep -E '\.(yaml|yml)' | grep -v '^integration-tests/terminal-bench/ci-tasks/' | xargs yamllint --format github + run: 'node scripts/lint.js --yamllint' - # - # Lint: All - # - # This is a virtual job that other jobs depend on to wait for all linters to - # finish. It's also used to ensure linting happens on CI via required - # workflows. - lint: - name: 'Lint' - needs: - - 'lint_github_actions' - - 'lint_javascript' - - 'lint_shell' - - 'lint_yaml' - runs-on: 'ubuntu-latest' - steps: - - run: |- - echo 'All linters finished!' + - name: 'Run Prettier' + run: 'node scripts/lint.js --prettier' + + - name: 'Run sensitive keyword linter' + run: 'node scripts/lint.js --sensitive-keywords' # # Test: Node diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml index 3ee0c757..40e6353f 100644 --- a/.github/workflows/gemini-self-assign-issue.yml +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -50,7 +50,8 @@ jobs: // 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` + q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`, + advanced_search: true }); if (assignedIssues.total_count >= MAX_ISSUES_ASSIGNED) { diff --git a/.github/workflows/qwen-automated-issue-triage.yml b/.github/workflows/qwen-automated-issue-triage.yml index 5b5a556e..6f91efb6 100644 --- a/.github/workflows/qwen-automated-issue-triage.yml +++ b/.github/workflows/qwen-automated-issue-triage.yml @@ -16,7 +16,7 @@ on: type: 'number' concurrency: - group: '${{ github.workflow }}-${{ github.event.issue.number }}' + group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}' cancel-in-progress: true defaults: @@ -29,6 +29,7 @@ permissions: issues: 'write' statuses: 'write' packages: 'read' + actions: 'write' # Required for cancelling a workflow run jobs: triage-issue: @@ -62,9 +63,9 @@ jobs: ## Role You are an issue triage assistant. Analyze the current GitHub issue - and identify the most appropriate existing labels. Use the available + and identify the most appropriate existing labels by only using the provided data. Use the available tools to gather information; do not ask for information to be - provided. Do not remove labels titled help wanted or good first issue. + provided. Do not remove the following labels titled maintainer, help wanted or good first issue. ## Steps @@ -92,23 +93,29 @@ jobs: Categorization Guidelines: P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. + - A P0 bug is a catastrophic failure that demands immediate attention. + - To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product. + - You would see this in the form of many comments from different developers on the bug. + - It represents a complete showstopper for a significant portion of users or for the development process itself. Impact: - Blocks development or testing for the entire team. - Major security vulnerability that could compromise user data or system integrity. - Causes data loss or corruption with no workaround. - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker? Qualifier: Is the main function of the software broken? - Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. + Example: The qwen auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. + - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. + - While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. + - Once again this would be affecting many users. + - You would see this in the form of comments from different developers on the bug. Impact: - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - Severe performance degradation making the application frustratingly slow. - No straightforward workaround exists, or the workaround is difficult and non-obvious. Qualifier: Is a key feature unusable or giving very wrong results? - Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. + Example: Qwen Code enters a loop when making read-many-files tool call. I am unable to break out of the loop and qwen doesn't follow instructions subsequently. P2: Medium - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. Impact: diff --git a/.github/workflows/qwen-scheduled-issue-triage.yml b/.github/workflows/qwen-scheduled-issue-triage.yml index 9b9a94f0..42812118 100644 --- a/.github/workflows/qwen-scheduled-issue-triage.yml +++ b/.github/workflows/qwen-scheduled-issue-triage.yml @@ -43,7 +43,7 @@ jobs: echo '🏷️ Finding issues that need triage...' NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)" + --search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)" echo '🔄 Merging and deduplicating issues...' ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')" @@ -111,7 +111,7 @@ jobs: - 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 labels titled help wanted or good first issue. + - Do not remove the following labels maintainer, help wanted or good first issue. - Triage only the current issue. - Identify only one category/ label - Identify only one type/ label (Do not apply type/duplicate or type/parent-issue) @@ -119,8 +119,11 @@ jobs: - 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: P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. - Impact: + - A P0 bug is a catastrophic failure that demands immediate attention. + - To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product. + - You would see this in the form of many comments from different developers on the bug. + - It represents a complete showstopper for a significant portion of users or for the development process itself. + Impact: - Blocks development or testing for the entire team. - Major security vulnerability that could compromise user data or system integrity. - Causes data loss or corruption with no workaround. @@ -129,15 +132,17 @@ jobs: Qualifier: Is the main function of the software broken? Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. - - Feature requests are almost never P1. + - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. + - While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. + - Once again this would be affecting many users. + - You would see this in the form of comments from different developers on the bug. Impact: - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - Severe performance degradation making the application frustratingly slow. - No straightforward workaround exists, or the workaround is difficult and non-obvious. Qualifier: Is a key feature unusable or giving very wrong results? - Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. + Example: Gemini CLI enters a loop when making read-many-files tool call. I am unable to break out of the loop and gemini doesn't follow instructions subsequently. P2: Medium - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. Impact: diff --git a/.gitignore b/.gitignore index 92341b1e..2c3156b9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,14 +3,21 @@ .env~ # gemini-cli settings -.gemini/ -!gemini/config.yaml +# 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/ # Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images # Dependency directory node_modules bower_components +package-lock.json # Editors .idea @@ -48,4 +55,5 @@ logs/ # GHA credentials gha-creds-*.json -QWEN.md \ No newline at end of file +# Log files +patch_output.log diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..cd40e916 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,9 @@ +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 +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fad7e1..ea273576 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,6 @@ }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "vitest.disableWorkspaceWarning": true } diff --git a/.yamllint.yml b/.yamllint.yml index b4612e07..a98b6dbb 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -86,3 +86,5 @@ ignore: - 'thirdparty/' - 'third_party/' - 'vendor/' + - 'node_modules/' + - 'integration-tests/terminal-bench/' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40c91a8f..a8f053ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,7 +136,7 @@ To start the Gemini CLI from the source code (after building), run the following 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` +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 diff --git a/LICENSE b/LICENSE index 8c57e0c4..2d4f1fee 100644 --- a/LICENSE +++ b/LICENSE @@ -200,4 +200,4 @@ 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. + limitations under the License. \ No newline at end of file diff --git a/docs/checkpointing.md b/docs/checkpointing.md index 2c5af00d..43af7102 100644 --- a/docs/checkpointing.md +++ b/docs/checkpointing.md @@ -38,8 +38,10 @@ Add the following key to your `settings.json`: ```json { - "checkpointing": { - "enabled": true + "general": { + "checkpointing": { + "enabled": true + } } } ``` diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index 1ad751c9..43f55d53 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -131,13 +131,7 @@ OpenAI-compatible API method if configured: **Example for headless environments:** -```bash -export OPENAI_API_KEY="your-api-key" -export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" -export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" +If none of these environment variables are set in a non-interactive session, the CLI will exit with an error. -# Run Qwen Code -qwen -``` - -If no API key is set in a non-interactive session, the CLI will exit with an error prompting you to configure authentication. +For comprehensive guidance on using Qwen COde programmatically and in +automation workflows, see the [Headless Mode Guide](../headless.md). diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 4c66d6d2..1e222fd1 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -9,7 +9,7 @@ Slash commands provide meta-level control over the CLI itself. ### Built-in Commands - **`/bug`** - - **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `bugCommand` setting in your `.qwen/settings.json` files. + - **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. 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 `.qwen/settings.json` files. - **`/chat`** - **Description:** Save and resume conversation history for branching conversation state interactively, or resuming a previous state from a later session. @@ -30,6 +30,9 @@ Slash commands provide meta-level control over the CLI itself. - **`delete`** - **Description:** Deletes a saved conversation checkpoint. - **Usage:** `/chat delete ` + - **`share`** + - **Description** Writes the current conversation to a provided Markdown or JSON file. + - **Usage** `/chat share file.md` or `/chat share file.json`. If no filename is provided, then the CLI will generate one. - **`/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. @@ -162,9 +165,6 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. -- **`/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-confirm`** - **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session. - **Usage:** `/quit-confirm` @@ -435,6 +435,16 @@ That's it! You can now run your command in the CLI. First, you might add a file Qwen Code will then execute the multi-line prompt defined in your TOML file. +## Input Prompt Shortcuts + +These shortcuts apply directly to the input prompt for text manipulation. + +- **Undo:** + - **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input prompt. + +- **Redo:** + - **Keyboard shortcut:** Press **Ctrl+Shift+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 the model. These commands include git-aware filtering. @@ -450,7 +460,7 @@ At commands are used to include the content of files or directories as part of y - 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 model. - - **Git-aware filtering:** By default, git-ignored files (like `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can be changed via the `fileFiltering` settings. + - **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. diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md new file mode 100644 index 00000000..0a16eb2e --- /dev/null +++ b/docs/cli/configuration-v1.md @@ -0,0 +1,670 @@ +# Qwen Code Configuration + +Qwen Code 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 + +Qwen Code uses JSON settings files for persistent configuration. There are four locations for these files: + +- **System defaults file:** + - **Location:** `/etc/qwen-code/system-defaults.json` (Linux), `C:\ProgramData\qwen-code\system-defaults.json` (Windows) or `/Library/Application Support/QwenCode/system-defaults.json` (macOS). The path can be overridden using the `QWEN_CODE_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:** `~/.qwen/settings.json` (where `~` is your home directory). + - **Scope:** Applies to all Qwen Code sessions for the current user. +- **Project settings file:** + - **Location:** `.qwen/settings.json` within your project's root directory. + - **Scope:** Applies only when running Qwen Code from that specific project. Project settings override user settings. + +- **System settings file:** + - **Location:** `/etc/qwen-code/settings.json` (Linux), `C:\ProgramData\qwen-code\settings.json` (Windows) or `/Library/Application Support/QwenCode/settings.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable. + - **Scope:** Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups. + +**Note on environment variables in settings:** String values within your `settings.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"`. + +### The `.qwen` directory in your project + +In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: + +- [Custom sandbox profiles](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). + +### Available settings in `settings.json`: + +- **`contextFileName`** (string or array of strings): + - **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames. + - **Default:** `QWEN.md` + - **Example:** `"contextFileName": "AGENTS.md"` + +- **`bugCommand`** (object): + - **Description:** Overrides the default URL for the `/bug` command. + - **Default:** `"urlTemplate": "https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}"` + - **Properties:** + - **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` placeholders. + - **Example:** + ```json + "bugCommand": { + "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" + } + ``` + +- **`fileFiltering`** (object): + - **Description:** Controls git-aware file filtering behavior for @ commands and file discovery tools. + - **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true` + - **Properties:** + - **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns when discovering files. When set to `true`, git-ignored files (like `node_modules/`, `dist/`, `.env`) are automatically excluded from @ commands and file listing operations. + - **`enableRecursiveFileSearch`** (boolean): Whether to enable searching recursively for filenames under the current tree when completing @ prefixes in the prompt. + - **`disableFuzzySearch`** (boolean): When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. + - **Example:** + ```json + "fileFiltering": { + "respectGitIgnore": true, + "enableRecursiveFileSearch": false, + "disableFuzzySearch": true + } + ``` + +### Troubleshooting File Search Performance + +If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: + +1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. + +2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. + +3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. + +- **`coreTools`** (array of strings): + - **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed. + - **Default:** All tools available for use by the model. + - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. + +- **`allowedTools`** (array of strings): + - **Default:** `undefined` + - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The match semantics are the same as `coreTools`. + - **Example:** `"allowedTools": ["ShellTool(git status)"]`. + +- **`excludeTools`** (array of strings): + - **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command. + - **Default**: No tools excluded. + - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. + - **Security Note:** Command-specific restrictions in + `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands + that can be executed. + +- **`allowMCPServers`** (array of strings): + - **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set. + - **Default:** All MCP servers are available for use by the model. + - **Example:** `"allowMCPServers": ["myPythonServer"]`. + - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. + +- **`excludeMCPServers`** (array of strings): + - **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set. + - **Default**: No MCP servers excluded. + - **Example:** `"excludeMCPServers": ["myNodeServer"]`. + - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. + +- **`autoAccept`** (boolean): + - **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. + - **Default:** `false` + - **Example:** `"autoAccept": true` + +- **`theme`** (string): + - **Description:** Sets the visual [theme](./themes.md) for Qwen Code. + - **Default:** `"Default"` + - **Example:** `"theme": "GitHub"` + +- **`vimMode`** (boolean): + - **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions. + - **Default:** `false` + - **Example:** `"vimMode": true` + +- **`sandbox`** (boolean or string): + - **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Qwen Code uses a pre-built `qwen-code-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing). + - **Default:** `false` + - **Example:** `"sandbox": "docker"` + +- **`toolDiscoveryCommand`** (string): + - **Description:** **Align with Gemini CLI.** Defines a custom shell command for discovering tools from your project. The shell command must return on `stdout` a JSON array of [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). Tool wrappers are optional. + - **Default:** Empty + - **Example:** `"toolDiscoveryCommand": "bin/get_tools"` + +- **`toolCallCommand`** (string): + - **Description:** **Align with Gemini CLI.** Defines a custom shell command for calling a specific tool that was discovered using `toolDiscoveryCommand`. The shell command must meet the following criteria: + - It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. + - It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). + - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). + - **Default:** Empty + - **Example:** `"toolCallCommand": "bin/call_tool"` + +- **`mcpServers`** (object): + - **Description:** Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `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`. + - **Default:** Empty + - **Properties:** + - **``** (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 (whitelist 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. + - **Example:** + ```json + "mcpServers": { + "myPythonServer": { + "command": "python", + "args": ["mcp_server.py", "--port", "8080"], + "cwd": "./mcp_tools/python", + "timeout": 5000, + "includeTools": ["safe_tool", "file_reader"], + }, + "myNodeServer": { + "command": "node", + "args": ["mcp_server.js"], + "cwd": "./mcp_tools/node", + "excludeTools": ["dangerous_tool", "file_deleter"] + }, + "myDockerServer": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"], + "env": { + "API_KEY": "$MY_API_TOKEN" + } + }, + "mySseServer": { + "url": "http://localhost:8081/events", + "headers": { + "Authorization": "Bearer $MY_SSE_TOKEN" + }, + "description": "An example SSE-based MCP server." + }, + "myStreamableHttpServer": { + "httpUrl": "http://localhost:8082/stream", + "headers": { + "X-API-Key": "$MY_HTTP_API_KEY" + }, + "description": "An example Streamable HTTP-based MCP server." + } + } + ``` + +- **`checkpointing`** (object): + - **Description:** Configures the checkpointing feature, which allows you to save and restore conversation and file states. See the [Checkpointing documentation](../checkpointing.md) for more details. + - **Default:** `{"enabled": false}` + - **Properties:** + - **`enabled`** (boolean): When `true`, the `/restore` command is available. + +- **`preferredEditor`** (string): + - **Description:** Specifies the preferred editor to use for viewing diffs. + - **Default:** `vscode` + - **Example:** `"preferredEditor": "vscode"` + +- **`telemetry`** (object) + - **Description:** Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md). + - **Default:** `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}` + - **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. + - **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs. + - **Example:** + ```json + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://localhost:16686", + "logPrompts": false + } + ``` +- **`usageStatisticsEnabled`** (boolean): + - **Description:** Enables or disables the collection of usage statistics. See [Usage Statistics](#usage-statistics) for more information. + - **Default:** `true` + - **Example:** + ```json + "usageStatisticsEnabled": false + ``` + +- **`hideTips`** (boolean): + - **Description:** Enables or disables helpful tips in the CLI interface. + - **Default:** `false` + - **Example:** + + ```json + "hideTips": true + ``` + +- **`hideBanner`** (boolean): + - **Description:** Enables or disables the startup banner (ASCII art logo) in the CLI interface. + - **Default:** `false` + - **Example:** + + ```json + "hideBanner": true + ``` + +- **`maxSessionTurns`** (number): + - **Description:** Sets the maximum number of turns for a session. If the session exceeds this limit, the CLI will stop processing and start a new chat. + - **Default:** `-1` (unlimited) + - **Example:** + ```json + "maxSessionTurns": 10 + ``` + +- **`summarizeToolOutput`** (object): + - **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. + - Note: Currently only the `run_shell_command` tool is supported. + - **Default:** `{}` (Disabled by default) + - **Example:** + ```json + "summarizeToolOutput": { + "run_shell_command": { + "tokenBudget": 2000 + } + } + ``` + +- **`excludedProjectEnvVars`** (array of strings): + - **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. + - **Default:** `["DEBUG", "DEBUG_MODE"]` + - **Example:** + ```json + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + ``` + +- **`includeDirectories`** (array of strings): + - **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. + - **Default:** `[]` + - **Example:** + ```json + "includeDirectories": [ + "/path/to/another/project", + "../shared-library", + "~/common-utils" + ] + ``` + +- **`loadMemoryFromIncludeDirectories`** (boolean): + - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. + - **Default:** `false` + - **Example:** + ```json + "loadMemoryFromIncludeDirectories": true + ``` + +- **`tavilyApiKey`** (string): + - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Default:** `undefined` (web search disabled) + - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` +- **`chatCompression`** (object): + - **Description:** Controls the settings for chat history compression, both automatic and + when manually invoked through the /compress command. + - **Properties:** + - **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. + - **Example:** + ```json + "chatCompression": { + "contextPercentageThreshold": 0.6 + } + ``` + +- **`showLineNumbers`** (boolean): + - **Description:** Controls whether line numbers are displayed in code blocks in the CLI output. + - **Default:** `true` + - **Example:** + ```json + "showLineNumbers": false + ``` + +- **`accessibility`** (object): + - **Description:** Configures accessibility features for the CLI. + - **Properties:** + - **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting. + - **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations. + - **Default:** `{"screenReader": false, "disableLoadingPhrases": false}` + - **Example:** + ```json + "accessibility": { + "screenReader": true, + "disableLoadingPhrases": true + } + ``` + +- **`skipNextSpeakerCheck`** (boolean): + - **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking. + - **Default:** `false` + - **Example:** + ```json + "skipNextSpeakerCheck": true + ``` + +- **`skipLoopDetection`** (boolean): + - **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. + - **Default:** `false` + - **Example:** + ```json + "skipLoopDetection": true + ``` + +- **`approvalMode`** (string): + - **Description:** Sets the default approval mode for tool usage. Accepted values are: + - `plan`: Analyze only, do not modify files or execute commands. + - `default`: Require approval before file edits or shell commands run. + - `auto-edit`: Automatically approve file edits. + - `yolo`: Automatically approve all tool calls. + - **Default:** `"default"` + - **Example:** + ```json + "approvalMode": "plan" + ``` + +### Example `settings.json`: + +```json +{ + "theme": "GitHub", + "sandbox": "docker", + "toolDiscoveryCommand": "bin/get_tools", + "toolCallCommand": "bin/call_tool", + "tavilyApiKey": "$TAVILY_API_KEY", + "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 + }, + "usageStatisticsEnabled": true, + "hideTips": false, + "hideBanner": false, + "skipNextSpeakerCheck": false, + "skipLoopDetection": false, + "maxSessionTurns": 10, + "summarizeToolOutput": { + "run_shell_command": { + "tokenBudget": 100 + } + }, + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadMemoryFromIncludeDirectories": true +} +``` + +## 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:** `~/.qwen/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 & `.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](./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 project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file. + +- **`OPENAI_API_KEY`**: + - One of several available [authentication methods](./authentication.md). + - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. +- **`OPENAI_BASE_URL`**: + - One of several available [authentication methods](./authentication.md). + - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. +- **`OPENAI_MODEL`**: + - Specifies the default OPENAI model to use. + - Overrides the hardcoded default + - Example: `export OPENAI_MODEL="qwen3-coder-plus"` +- **`GEMINI_SANDBOX`**: + - Alternative to the `sandbox` setting in `settings.json`. + - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. +- **`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. + - `strict`: Uses a strict profile that declines operations by default. + - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/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 the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code 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. +- **`TAVILY_API_KEY`**: + - Your API key for the Tavily web search service. + - Required to enable the `web_search` tool functionality. + - If not configured, the web search tool will be disabled and skipped. + - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` + +## Command-Line Arguments + +Arguments passed directly when running the CLI can override other configurations for that specific session. + +- **`--model `** (**`-m `**): + - Specifies the Qwen model to use for this session. + - Example: `npm start -- --model qwen3-coder-plus` +- **`--prompt `** (**`-p `**): + - Used to pass a prompt directly to the command. This invokes Qwen Code 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: `qwen -i "explain this code"` +- **`--sandbox`** (**`-s`**): + - Enables sandbox mode for this session. +- **`--sandbox-image`**: + - Sets the sandbox image URI. +- **`--debug`** (**`-d`**): + - Enables debug mode for this session, providing more verbose output. +- **`--all-files`** (**`-a`**): + - If set, recursively includes all files within the current directory as context for the prompt. +- **`--help`** (or **`-h`**): + - Displays help information about command-line arguments. +- **`--show-memory-usage`**: + - Displays the current memory usage. +- **`--yolo`**: + - Enables YOLO mode, which automatically approves all tool calls. +- **`--approval-mode `**: + - Sets the approval mode for tool calls. Supported modes: + - `plan`: Analyze only—do not modify files or execute commands. + - `default`: Require approval for file edits or shell commands (default behavior). + - `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. + - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). + - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. + - Example: `qwen --approval-mode auto-edit` +- **`--allowed-tools `**: + - A comma-separated list of tool names that will bypass the confirmation dialog. + - Example: `qwen --allowed-tools "ShellTool(git status)"` +- **`--telemetry`**: + - Enables [telemetry](../telemetry.md). +- **`--telemetry-target`**: + - Sets the telemetry target. See [telemetry](../telemetry.md) for more information. +- **`--telemetry-otlp-endpoint`**: + - Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for more information. +- **`--telemetry-otlp-protocol`**: + - Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`. See [telemetry](../telemetry.md) for more information. +- **`--telemetry-log-prompts`**: + - Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information. +- **`--checkpointing`**: + - Enables [checkpointing](../checkpointing.md). +- **`--extensions `** (**`-e `**): + - Specifies a list of extensions to use for the session. If not provided, all available extensions are used. + - Use the special term `qwen -e none` to disable all extensions. + - Example: `qwen -e my-extension -e my-other-extension` +- **`--list-extensions`** (**`-l`**): + - Lists all available extensions and exits. +- **`--proxy`**: + - Sets the proxy for the CLI. + - Example: `--proxy http://localhost:7890`. +- **`--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 for accessibility. +- **`--version`**: + - Displays the version of the CLI. +- **`--openai-logging`**: + - Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. +- **`--tavily-api-key `**: + - Sets the Tavily API key for web search functionality for this session. + - Example: `qwen --tavily-api-key tvly-your-api-key-here` + +## Context Files (Hierarchical Instructional Context) + +While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). 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 Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. + +### Example Context File Content (e.g., `QWEN.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., `QWEN.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: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). + - Scope: Provides default instructions for all your projects. + 2. **Project Root & 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 a `memoryDiscoveryMaxDirs` field in your `settings.json` file. + - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. +- **Concatenation & 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. 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](../core/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 `refresh`). + +By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. + +## Sandboxing + +Qwen Code 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 `qwen-code-sandbox` Docker image. + +For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image: + +```dockerfile +FROM qwen-code-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 `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Qwen Code to automatically build the custom sandbox image: + +```bash +BUILD_SANDBOX=1 qwen -s +``` + +## Usage Statistics + +To help us improve Qwen Code, 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 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 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` in your `settings.json` file: + +```json +{ + "usageStatisticsEnabled": false +} +``` + +Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. + +- **`enableWelcomeBack`** (boolean): + - **Description:** Show welcome back dialog when returning to a project with conversation history. + - **Default:** `true` + - **Category:** UI + - **Requires Restart:** No + - **Example:** `"enableWelcomeBack": false` + - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/chat summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 0a16eb2e..e2067290 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -1,5 +1,11 @@ # Qwen Code Configuration +**Note on New Configuration Format** + +The format of the `settings.json` file has been updated to a new, more organized structure. The old format will be migrated automatically. + +For details on the previous format, please see the [v1 Configuration documentation](./configuration-v1.md). + Qwen Code 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 @@ -40,349 +46,317 @@ In addition to a project settings file, a project's `.qwen` directory can contai - [Custom sandbox profiles](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). -### Available settings in `settings.json`: +### Available settings in `settings.json` -- **`contextFileName`** (string or array of strings): - - **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames. - - **Default:** `QWEN.md` - - **Example:** `"contextFileName": "AGENTS.md"` +Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. -- **`bugCommand`** (object): - - **Description:** Overrides the default URL for the `/bug` command. - - **Default:** `"urlTemplate": "https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}"` - - **Properties:** - - **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` placeholders. - - **Example:** - ```json - "bugCommand": { - "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" - } - ``` +#### `general` -- **`fileFiltering`** (object): - - **Description:** Controls git-aware file filtering behavior for @ commands and file discovery tools. - - **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true` - - **Properties:** - - **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns when discovering files. When set to `true`, git-ignored files (like `node_modules/`, `dist/`, `.env`) are automatically excluded from @ commands and file listing operations. - - **`enableRecursiveFileSearch`** (boolean): Whether to enable searching recursively for filenames under the current tree when completing @ prefixes in the prompt. - - **`disableFuzzySearch`** (boolean): When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. - - **Example:** - ```json - "fileFiltering": { - "respectGitIgnore": true, - "enableRecursiveFileSearch": false, - "disableFuzzySearch": true - } - ``` - -### Troubleshooting File Search Performance - -If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: - -1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. - -2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. - -3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. - -- **`coreTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed. - - **Default:** All tools available for use by the model. - - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. - -- **`allowedTools`** (array of strings): +- **`general.preferredEditor`** (string): + - **Description:** The preferred editor to open files in. - **Default:** `undefined` - - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The match semantics are the same as `coreTools`. - - **Example:** `"allowedTools": ["ShellTool(git status)"]`. -- **`excludeTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command. - - **Default**: No tools excluded. - - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. - - **Security Note:** Command-specific restrictions in - `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands - that can be executed. - -- **`allowMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set. - - **Default:** All MCP servers are available for use by the model. - - **Example:** `"allowMCPServers": ["myPythonServer"]`. - - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. - -- **`excludeMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set. - - **Default**: No MCP servers excluded. - - **Example:** `"excludeMCPServers": ["myNodeServer"]`. - - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. - -- **`autoAccept`** (boolean): - - **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. +- **`general.vimMode`** (boolean): + - **Description:** Enable Vim keybindings. - **Default:** `false` - - **Example:** `"autoAccept": true` -- **`theme`** (string): - - **Description:** Sets the visual [theme](./themes.md) for Qwen Code. - - **Default:** `"Default"` - - **Example:** `"theme": "GitHub"` - -- **`vimMode`** (boolean): - - **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions. +- **`general.disableAutoUpdate`** (boolean): + - **Description:** Disable automatic updates. - **Default:** `false` - - **Example:** `"vimMode": true` -- **`sandbox`** (boolean or string): - - **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Qwen Code uses a pre-built `qwen-code-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing). +- **`general.disableUpdateNag`** (boolean): + - **Description:** Disable update notification prompts. - **Default:** `false` - - **Example:** `"sandbox": "docker"` -- **`toolDiscoveryCommand`** (string): - - **Description:** **Align with Gemini CLI.** Defines a custom shell command for discovering tools from your project. The shell command must return on `stdout` a JSON array of [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). Tool wrappers are optional. - - **Default:** Empty - - **Example:** `"toolDiscoveryCommand": "bin/get_tools"` +- **`general.checkpointing.enabled`** (boolean): + - **Description:** Enable session checkpointing for recovery. + - **Default:** `false` -- **`toolCallCommand`** (string): - - **Description:** **Align with Gemini CLI.** Defines a custom shell command for calling a specific tool that was discovered using `toolDiscoveryCommand`. The shell command must meet the following criteria: - - It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. - - It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). - - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). - - **Default:** Empty - - **Example:** `"toolCallCommand": "bin/call_tool"` +#### `output` -- **`mcpServers`** (object): - - **Description:** Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `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`. - - **Default:** Empty - - **Properties:** - - **``** (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 (whitelist 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. - - **Example:** - ```json - "mcpServers": { - "myPythonServer": { - "command": "python", - "args": ["mcp_server.py", "--port", "8080"], - "cwd": "./mcp_tools/python", - "timeout": 5000, - "includeTools": ["safe_tool", "file_reader"], - }, - "myNodeServer": { - "command": "node", - "args": ["mcp_server.js"], - "cwd": "./mcp_tools/node", - "excludeTools": ["dangerous_tool", "file_deleter"] - }, - "myDockerServer": { - "command": "docker", - "args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"], - "env": { - "API_KEY": "$MY_API_TOKEN" - } - }, - "mySseServer": { - "url": "http://localhost:8081/events", - "headers": { - "Authorization": "Bearer $MY_SSE_TOKEN" - }, - "description": "An example SSE-based MCP server." - }, - "myStreamableHttpServer": { - "httpUrl": "http://localhost:8082/stream", - "headers": { - "X-API-Key": "$MY_HTTP_API_KEY" - }, - "description": "An example Streamable HTTP-based MCP server." - } - } - ``` +- **`output.format`** (string): + - **Description:** The format of the CLI output. + - **Default:** `"text"` + - **Values:** `"text"`, `"json"` -- **`checkpointing`** (object): - - **Description:** Configures the checkpointing feature, which allows you to save and restore conversation and file states. See the [Checkpointing documentation](../checkpointing.md) for more details. - - **Default:** `{"enabled": false}` - - **Properties:** - - **`enabled`** (boolean): When `true`, the `/restore` command is available. +#### `ui` -- **`preferredEditor`** (string): - - **Description:** Specifies the preferred editor to use for viewing diffs. - - **Default:** `vscode` - - **Example:** `"preferredEditor": "vscode"` +- **`ui.theme`** (string): + - **Description:** The color theme for the UI. See [Themes](./themes.md) for available options. + - **Default:** `undefined` -- **`telemetry`** (object) - - **Description:** Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md). - - **Default:** `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}` - - **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. - - **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs. - - **Example:** - ```json - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:16686", - "logPrompts": false - } - ``` -- **`usageStatisticsEnabled`** (boolean): - - **Description:** Enables or disables the collection of usage statistics. See [Usage Statistics](#usage-statistics) for more information. +- **`ui.customThemes`** (object): + - **Description:** Custom theme definitions. + - **Default:** `{}` + +- **`ui.hideWindowTitle`** (boolean): + - **Description:** Hide the window title bar. + - **Default:** `false` + +- **`ui.hideTips`** (boolean): + - **Description:** Hide helpful tips in the UI. + - **Default:** `false` + +- **`ui.hideBanner`** (boolean): + - **Description:** Hide the application banner. + - **Default:** `false` + +- **`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:** `false` + +- **`ui.showCitations`** (boolean): + - **Description:** Show citations for generated text in the chat. - **Default:** `true` - - **Example:** - ```json - "usageStatisticsEnabled": false - ``` -- **`hideTips`** (boolean): - - **Description:** Enables or disables helpful tips in the CLI interface. +- **`enableWelcomeBack`** (boolean): + - **Description:** Show welcome back dialog when returning to a project with conversation history. + - **Default:** `true` + +- **`ui.accessibility.disableLoadingPhrases`** (boolean): + - **Description:** Disable loading phrases for accessibility. - **Default:** `false` - - **Example:** - ```json - "hideTips": true - ``` - -- **`hideBanner`** (boolean): - - **Description:** Enables or disables the startup banner (ASCII art logo) in the CLI interface. - - **Default:** `false` - - **Example:** - - ```json - "hideBanner": true - ``` - -- **`maxSessionTurns`** (number): - - **Description:** Sets the maximum number of turns for a session. If the session exceeds this limit, the CLI will stop processing and start a new chat. - - **Default:** `-1` (unlimited) - - **Example:** - ```json - "maxSessionTurns": 10 - ``` - -- **`summarizeToolOutput`** (object): - - **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. - - Note: Currently only the `run_shell_command` tool is supported. - - **Default:** `{}` (Disabled by default) - - **Example:** - ```json - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 2000 - } - } - ``` - -- **`excludedProjectEnvVars`** (array of strings): - - **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. - - **Default:** `["DEBUG", "DEBUG_MODE"]` - - **Example:** - ```json - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] - ``` - -- **`includeDirectories`** (array of strings): - - **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. +- **`ui.customWittyPhrases`** (array of strings): + - **Description:** A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. - **Default:** `[]` - - **Example:** - ```json - "includeDirectories": [ - "/path/to/another/project", - "../shared-library", - "~/common-utils" - ] - ``` -- **`loadMemoryFromIncludeDirectories`** (boolean): +#### `ide` + +- **`ide.enabled`** (boolean): + - **Description:** Enable IDE integration mode. + - **Default:** `false` + +- **`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` + +#### `model` + +- **`model.name`** (string): + - **Description:** The Qwen 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 the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` + - **Default:** `undefined` + +- **`model.chatCompression.contextPercentageThreshold`** (number): + - **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. + - **Default:** `0.7` + +- **`model.skipNextSpeakerCheck`** (boolean): + - **Description:** Skip the next speaker check. + - **Default:** `false` + +- **`model.skipLoopDetection`**(boolean): + - **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. + - **Default:** `false` + +#### `context` + +- **`context.fileName`** (string or array of strings): + - **Description:** The name of the context file(s). + - **Default:** `undefined` + +- **`context.importFormat`** (string): + - **Description:** The format to use when importing memory. + - **Default:** `undefined` + +- **`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.loadFromIncludeDirectories`** (boolean): - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. - **Default:** `false` - - **Example:** - ```json - "loadMemoryFromIncludeDirectories": true - ``` -- **`tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. - - **Default:** `undefined` (web search disabled) - - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` -- **`chatCompression`** (object): - - **Description:** Controls the settings for chat history compression, both automatic and - when manually invoked through the /compress command. - - **Properties:** - - **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. - - **Example:** - ```json - "chatCompression": { - "contextPercentageThreshold": 0.6 - } - ``` - -- **`showLineNumbers`** (boolean): - - **Description:** Controls whether line numbers are displayed in code blocks in the CLI output. +- **`context.fileFiltering.respectGitIgnore`** (boolean): + - **Description:** Respect .gitignore files when searching. - **Default:** `true` - - **Example:** - ```json - "showLineNumbers": false - ``` -- **`accessibility`** (object): - - **Description:** Configures accessibility features for the CLI. - - **Properties:** - - **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting. - - **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations. - - **Default:** `{"screenReader": false, "disableLoadingPhrases": false}` - - **Example:** - ```json - "accessibility": { - "screenReader": true, - "disableLoadingPhrases": true - } - ``` +- **`context.fileFiltering.respectQwenIgnore`** (boolean): + - **Description:** Respect .qwenignore files when searching. + - **Default:** `true` -- **`skipNextSpeakerCheck`** (boolean): - - **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking. - - **Default:** `false` - - **Example:** - ```json - "skipNextSpeakerCheck": true - ``` +- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean): + - **Description:** Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. + - **Default:** `true` -- **`skipLoopDetection`** (boolean): - - **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - - **Default:** `false` - - **Example:** - ```json - "skipLoopDetection": true - ``` +#### `tools` -- **`approvalMode`** (string): +- **`tools.sandbox`** (boolean or string): + - **Description:** Sandbox execution environment (can be a boolean or a path string). + - **Default:** `undefined` + +- **`tools.shell.enableInteractiveShell`** (boolean): + + Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. Defaults to `false`. + +- **`tools.core`** (array of strings): + - **Description:** This can be used to restrict the set of built-in tools [with an allowlist](./enterprise.md#restricting-tool-access). See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. The match semantics are the same as `tools.allowed`. + - **Default:** `undefined` + +- **`tools.exclude`** (array of strings): + - **Description:** Tool names to exclude from discovery. + - **Default:** `undefined` + +- **`tools.allowed`** (array of strings): + - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. For example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will skip the confirmation dialog to run any `git` and `npm test` commands. See [Shell Tool command restrictions](../tools/shell.md#command-restrictions) for details on prefix matching, command chaining, etc. + - **Default:** `undefined` + +- **`tools.approvalMode`** (string): - **Description:** Sets the default approval mode for tool usage. Accepted values are: - `plan`: Analyze only, do not modify files or execute commands. - `default`: Require approval before file edits or shell commands run. - `auto-edit`: Automatically approve file edits. - `yolo`: Automatically approve all tool calls. - - **Default:** `"default"` - - **Example:** - ```json - "approvalMode": "plan" - ``` + - **Default:** `default` -### Example `settings.json`: +- **`tools.discoveryCommand`** (string): + - **Description:** Command to run for tool discovery. + - **Default:** `undefined` + +- **`tools.callCommand`** (string): + - **Description:** Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: + - It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. + - It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). + - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). + - **Default:** `undefined` + +#### `mcp` + +- **`mcp.serverCommand`** (string): + - **Description:** Command to start an MCP server. + - **Default:** `undefined` + +- **`mcp.allowed`** (array of strings): + - **Description:** An allowlist of MCP servers to allow. + - **Default:** `undefined` + +- **`mcp.excluded`** (array of strings): + - **Description:** A denylist of MCP servers to exclude. + - **Default:** `undefined` + +#### `security` + +- **`security.folderTrust.enabled`** (boolean): + - **Description:** Setting to track whether Folder trust is enabled. + - **Default:** `false` + +- **`security.auth.selectedType`** (string): + - **Description:** The currently selected authentication type. + - **Default:** `undefined` + +- **`security.auth.enforcedType`** (string): + - **Description:** The required auth type (useful for enterprises). + - **Default:** `undefined` + +- **`security.auth.useExternal`** (boolean): + - **Description:** Whether to use an external authentication flow. + - **Default:** `undefined` + +#### `advanced` + +- **`advanced.autoConfigureMemory`** (boolean): + - **Description:** Automatically configure Node.js memory limits. + - **Default:** `false` + +- **`advanced.dnsResolutionOrder`** (string): + - **Description:** The DNS resolution order. + - **Default:** `undefined` + +- **`advanced.excludedEnvVars`** (array of strings): + - **Description:** Environment variables to exclude from project context. + - **Default:** `["DEBUG","DEBUG_MODE"]` + +- **`advanced.bugCommand`** (object): + - **Description:** Configuration for the bug report command. + - **Default:** `undefined` + +- **`advanced.tavilyApiKey`** (string): + - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Default:** `undefined` + +#### `mcpServers` + +Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `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`. + +- **`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 Qwen Code. For more information, see [Telemetry](../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 { - "theme": "GitHub", - "sandbox": "docker", - "toolDiscoveryCommand": "bin/get_tools", - "toolCallCommand": "bin/call_tool", - "tavilyApiKey": "$TAVILY_API_KEY", + "general": { + "vimMode": true, + "preferredEditor": "code" + }, + "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": { + "approvalMode": "yolo", + "sandbox": "docker", + "discoveryCommand": "bin/get_tools", + "callCommand": "bin/call_tool", + "exclude": ["write_file"] + }, "mcpServers": { "mainServer": { "command": "bin/mcp_server.py" @@ -398,20 +372,29 @@ If you are experiencing performance issues with file searching (e.g., with `@` c "otlpEndpoint": "http://localhost:4317", "logPrompts": true }, - "usageStatisticsEnabled": true, - "hideTips": false, - "hideBanner": false, - "skipNextSpeakerCheck": false, - "skipLoopDetection": false, - "maxSessionTurns": 10, - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 100 + "privacy": { + "usageStatisticsEnabled": true + }, + "model": { + "name": "qwen3-coder-plus", + "maxSessionTurns": 10, + "summarizeToolOutput": { + "run_shell_command": { + "tokenBudget": 100 + } } }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], - "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], - "loadMemoryFromIncludeDirectories": true + "context": { + "fileName": ["CONTEXT.md", "QWEN.md"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadFromIncludeDirectories": true, + "fileFiltering": { + "respectGitIgnore": false + } + }, + "advanced": { + "excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + } } ``` @@ -433,7 +416,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi 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 project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file. +**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`OPENAI_API_KEY`**: - One of several available [authentication methods](./authentication.md). @@ -445,6 +428,27 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - Specifies the default OPENAI model to use. - Overrides the hardcoded default - Example: `export OPENAI_MODEL="qwen3-coder-plus"` +- **`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. - **`GEMINI_SANDBOX`**: - Alternative to the `sandbox` setting in `settings.json`. - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. @@ -460,9 +464,6 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - 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. - **`TAVILY_API_KEY`**: - Your API key for the Tavily web search service. - Required to enable the `web_search` tool functionality. @@ -478,11 +479,18 @@ Arguments passed directly when running the CLI can override other configurations - Example: `npm start -- --model qwen3-coder-plus` - **`--prompt `** (**`-p `**): - Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. + - For scripting examples, use the `--output-format json` flag to get structured output. - **`--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: `qwen -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. + - **Note:** For structured output and scripting, use the `--output-format json` flag. - **`--sandbox`** (**`-s`**): - Enables sandbox mode for this session. - **`--sandbox-image`**: @@ -535,7 +543,7 @@ Arguments passed directly when running the CLI can override other configurations - 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 for accessibility. + - Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. - **`--version`**: - Displays the version of the CLI. - **`--openai-logging`**: @@ -546,7 +554,7 @@ Arguments passed directly when running the CLI can override other configurations ## Context Files (Hierarchical Instructional Context) -While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). 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. +While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). 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 Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. @@ -587,13 +595,13 @@ This example demonstrates how you can provide general project context, specific - **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.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: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). + - Location: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). - Scope: Provides default instructions for all your projects. 2. **Project Root & 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 a `memoryDiscoveryMaxDirs` field in your `settings.json` file. + - 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 & 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. 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](../core/memport.md). @@ -651,20 +659,14 @@ To help us improve Qwen Code, we collect anonymized usage statistics. This data **How to opt out:** -You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` in your `settings.json` file: +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 { - "usageStatisticsEnabled": false + "privacy": { + "usageStatisticsEnabled": false + } } ``` Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. - -- **`enableWelcomeBack`** (boolean): - - **Description:** Show welcome back dialog when returning to a project with conversation history. - - **Default:** `true` - - **Category:** UI - - **Requires Restart:** No - - **Example:** `"enableWelcomeBack": false` - - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/chat summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/cli/index.md b/docs/cli/index.md index e32eca14..6dd24b2a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -7,6 +7,7 @@ Within Qwen Code, `packages/cli` is the frontend for users to send and receive p - **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers. - **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`). - **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files. +- **[Headless Mode](../headless.md):** A comprehensive guide to using Qwen Code programmatically for scripting and automation. - **[Token Caching](./token-caching.md):** Optimize API costs through token caching. - **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes. - **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task. @@ -22,8 +23,10 @@ The following example pipes a command to Qwen Code from your terminal: echo "What is fine tuning?" | qwen ``` -Qwen Code executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example: +You can also use the `--prompt` or `-p` flag: ```bash qwen -p "What is fine tuning?" ``` + +For comprehensive documentation on headless usage, scripting, automation, and advanced examples, see the **[Headless Mode](../headless.md)** guide. diff --git a/docs/cli/themes.md b/docs/cli/themes.md index ad8a046a..3b262bc9 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -46,25 +46,14 @@ Add a `customThemes` block to your user, project, or system `settings.json` file ```json { - "customThemes": { - "MyCustomTheme": { - "name": "MyCustomTheme", - "type": "custom", - "Background": "#181818", - "Foreground": "#F8F8F2", - "LightBlue": "#82AAFF", - "AccentBlue": "#61AFEF", - "AccentPurple": "#C678DD", - "AccentCyan": "#56B6C2", - "AccentGreen": "#98C379", - "AccentYellow": "#E5C07B", - "AccentRed": "#E06C75", - "Comment": "#5C6370", - "Gray": "#ABB2BF", - "DiffAdded": "#A6E3A1", - "DiffRemoved": "#F38BA8", - "DiffModified": "#89B4FA", - "GradientColors": ["#4796E4", "#847ACE", "#C3677F"] + "ui": { + "customThemes": { + "MyCustomTheme": { + "name": "MyCustomTheme", + "type": "custom", + "Background": "#181818", + ... + } } } } @@ -115,7 +104,9 @@ To load a theme from a file, set the `theme` property in your `settings.json` to ```json { - "theme": "/path/to/your/theme.json" + "ui": { + "theme": "/path/to/your/theme.json" + } } ``` @@ -154,7 +145,7 @@ The theme file must be a valid JSON file that follows the same structure as a cu ### Using Your Custom Theme - Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog. -- Or, set it as the default by adding `"theme": "MyCustomTheme"` to your `settings.json`. +- 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](./configuration.md) as other settings. --- diff --git a/docs/core/memport.md b/docs/core/memport.md index 6b0a6909..3431653a 100644 --- a/docs/core/memport.md +++ b/docs/core/memport.md @@ -147,7 +147,7 @@ Processes import statements in context file content. - `debugMode` (boolean, optional): Whether to enable debug logging (default: false) - `importState` (ImportState, optional): State tracking for circular import prevention -**Returns:** Promise - Object containing processed content and import tree +**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree ### `ProcessImportsResult` @@ -187,7 +187,7 @@ Finds the project root by searching for a `.git` directory upwards from the give - `startDir` (string): The directory to start searching from -**Returns:** Promise - The project root directory (or the start directory if no `.git` is found) +**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found) ## Best Practices diff --git a/docs/core/tools-api.md b/docs/core/tools-api.md index 7c2fe758..4d788a68 100644 --- a/docs/core/tools-api.md +++ b/docs/core/tools-api.md @@ -23,8 +23,8 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi - **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for: - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`). - **Discovering Tools:** It can also discover tools dynamically: - - **Command-based Discovery:** If `toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances. - - **MCP-based Discovery:** If `mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`). + - **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances. + - **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`). - **Providing Schemas:** Exposing the `FunctionDeclaration` schemas of all registered tools to the model, so it knows what tools are available and how to use them. - **Retrieving Tools:** Allowing the core to get a specific tool by name for execution. @@ -69,7 +69,7 @@ Each of these tools extends `BaseTool` and implements the required methods for i While direct programmatic registration of new tools by users isn't explicitly detailed as a primary workflow in the provided files for typical end-users, the architecture supports extension through: -- **Command-based Discovery:** Advanced users or project administrators can define a `toolDiscoveryCommand` in `settings.json`. This command, when run by the core, should output a JSON array of `FunctionDeclaration` objects. The core will then make these available as `DiscoveredTool` instances. The corresponding `toolCallCommand` would then be responsible for actually executing these custom tools. +- **Command-based Discovery:** Advanced users or project administrators can define a `tools.toolDiscoveryCommand` in `settings.json`. This command, when run by the core, should output a JSON array of `FunctionDeclaration` objects. The core will then make these available as `DiscoveredTool` instances. The corresponding `tools.toolCallCommand` would then be responsible for actually executing these custom tools. - **MCP Server(s):** For more complex scenarios, one or more MCP servers can be set up and configured via the `mcpServers` setting in `settings.json`. The core can then discover and use tools exposed by these servers. As mentioned, if you have multiple MCP servers, the tool names will be prefixed with the server name from your configuration (e.g., `serverAlias__actualToolName`). This tool system provides a flexible and powerful way to augment the model's capabilities, making Qwen Code a versatile assistant for a wide range of tasks. diff --git a/docs/extension-releasing.md b/docs/extension-releasing.md new file mode 100644 index 00000000..0ec25fa7 --- /dev/null +++ b/docs/extension-releasing.md @@ -0,0 +1,121 @@ +# Extension Releasing + +There are two primary ways of releasing extensions to users: + +- [Git repository](#releasing-through-a-git-repository) +- [Github Releases](#releasing-through-github-releases) + +Git repository releases tend to be the simplest and most flexible approach, while GitHub releases can be more efficient on initial install as they are shipped as single archives instead of requiring a git clone which downloads each file individually. Github releases may also contain platform specific archives if you need to ship platform specific binary files. + +## Releasing through a git repository + +This is the most flexible and simple option. All you need to do us create a publicly accessible git repo (such as a public github repository) and then users can install your extension using `qwen extensions install `, or for a GitHub repository they can use the simplified `qwen extensions install /` format. They can optionally depend on a specific ref (branch/tag/commit) using the `--ref=` argument, this defaults to the default branch. + +Whenever commits are pushed to the ref that a user depends on, they will be prompted to update the extension. Note that this also allows for easy rollbacks, the HEAD commit is always treated as the latest version regardless of the actual version in the `qwen-extension.json` file. + +### Managing release channels using a git repository + +Users can depend on any ref from your git repo, such as a branch or tag, which allows you to manage multiple release channels. + +For instance, you can maintain a `stable` branch, which users can install this way `qwen extensions install --ref=stable`. Or, you could make this the default by treating your default branch as your stable release branch, and doing development in a different branch (for instance called `dev`). You can maintain as many branches or tags as you like, providing maximum flexibility for you and your users. + +Note that these `ref` arguments can be tags, branches, or even specific commits, which allows users to depend on a specific version of your extension. It is up to you how you want to manage your tags and branches. + +### Example releasing flow using a git repo + +While there are many options for how you want to manage releases using a git flow, we recommend treating your default branch as your "stable" release branch. This means that the default behavior for `qwen extensions install ` is to be on the stable release branch. + +Lets say you want to maintain three standard release channels, `stable`, `preview`, and `dev`. You would do all your standard development in the `dev` branch. When you are ready to do a preview release, you merge that branch into your `preview` branch. When you are ready to promote your preview branch to stable, you merge `preview` into your stable branch (which might be your default branch or a different branch). + +You can also cherry pick changes from one branch into another using `git cherry-pick`, but do note that this will result in your branches having a slightly divergent history from each other, unless you force push changes to your branches on each release to restore the history to a clean slate (which may not be possible for the default branch depending on your repository settings). If you plan on doing cherry picks, you may want to avoid having your default branch be the stable branch to avoid force-pushing to the default branch which should generally be avoided. + +## Releasing through Github releases + +Qwen Code extensions can be distributed through [GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases). This provides a faster and more reliable initial installation experience for users, as it avoids the need to clone the repository. + +Each release includes at least one archive file, which contains the full contents of the repo at the tag that it was linked to. Releases may also include [pre-built archives](#custom-pre-built-archives) if your extension requires some build step or has platform specific binaries attached to it. + +When checking for updates, qwen code will just look for the latest release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=`. We do not at this time support opting in to pre-release releases or semver. + +### Custom pre-built archives + +Custom archives must be attached directly to the github release as assets and must be fully self-contained. This means they should include the entire extension, see [archive structure](#archive-structure). + +If your extension is platform-independent, you can provide a single generic asset. In this case, there should be only one asset attached to the release. + +Custom archives may also be used if you want to develop your extension within a larger repository, you can build an archive which has a different layout from the repo itself (for instance it might just be an archive of a subdirectory containing the extension). + +#### Platform specific archives + +To ensure Qwen Code can automatically find the correct release asset for each platform, you must follow this naming convention. The CLI will search for assets in the following order: + +1. **Platform and Architecture-Specific:** `{platform}.{arch}.{name}.{extension}` +2. **Platform-Specific:** `{platform}.{name}.{extension}` +3. **Generic:** If only one asset is provided, it will be used as a generic fallback. + +- `{name}`: The name of your extension. +- `{platform}`: The operating system. Supported values are: + - `darwin` (macOS) + - `linux` + - `win32` (Windows) +- `{arch}`: The architecture. Supported values are: + - `x64` + - `arm64` +- `{extension}`: The file extension of the archive (e.g., `.tar.gz` or `.zip`). + +**Examples:** + +- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs) +- `darwin.my-tool.tar.gz` (for all Macs) +- `linux.x64.my-tool.tar.gz` +- `win32.my-tool.zip` + +#### Archive structure + +Archives must be fully contained extensions and have all the standard requirements - specifically the `qwen-extension.json` file must be at the root of the archive. + +The rest of the layout should look exactly the same as a typical extension, see [extensions.md](extension.md). + +#### Example GitHub Actions workflow + +Here is an example of a GitHub Actions workflow that builds and releases a Qwen Code 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 +``` diff --git a/docs/extension.md b/docs/extension.md index 358b666f..1986ffb9 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -1,19 +1,88 @@ # Qwen Code Extensions -Qwen Code supports extensions that can be used to configure and extend its functionality. +Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable. + +## Extension management + +We offer a suite of extension management tools using `qwen extensions` commands. + +Note that these commands are not supported from within the CLI, although you can list installed extensions using the `/extensions list` subcommand. + +Note that all of these commands will only be reflected in active CLI sessions on restart. + +### Installing an extension + +You can install an extension using `qwen extensions install` with either a GitHub URL or a local path`. + +Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub. + +``` +qwen extensions install https://github.com/qwen-cli-extensions/security +``` + +This will install the Qwen Code Security extension, which offers support for a `/security:analyze` command. + +### Uninstalling an extension + +To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example: + +``` +qwen extensions uninstall qwen-cli-security +``` + +### Disabling an extension + +Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace. + +For example, `qwen extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `qwen extensions disable extension-name --scope=workspace` will only disable the extension in the current workspace. + +### Enabling an extension + +You can enable extensions using `qwen extensions enable extension-name`. You can also enable an extension for a specific workspace using `qwen extensions enable extension-name --scope=workspace` from within that workspace. + +This is useful if you have an extension disabled at the top-level and only enabled in specific places. + +### Updating an extension + +For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `qwen-extension.json` `version` field) with `qwen extensions update extension-name`. + +You can update all extensions with: + +``` +qwen extensions update --all +``` + +## Extension creation + +We offer commands to make extension development easier. + +### Create a boilerplate extension + +We offer several example extensions `context`, `custom-commands`, `exclude-tools` and `mcp-server`. You can view these examples [here](https://github.com/QwenLM/qwen-code/tree/main/packages/cli/src/commands/extensions/examples). + +To copy one of these examples into a development directory using the type of your choosing, run: + +``` +qwen extensions new path/to/directory custom-commands +``` + +### Link a local extension + +The `qwen extensions link` command will create a symbolic link from the extension installation directory to the development path. + +This is useful so you don't have to run `qwen extensions update` every time you make changes you'd like to test. + +``` +qwen extensions link path/to/directory +``` ## How it works -On startup, Qwen Code looks for extensions in two locations: +On startup, Qwen Code looks for extensions in `/.qwen/extensions` -1. `/.qwen/extensions` -2. `/.qwen/extensions` +Extensions exist as a directory that contains a `qwen-extension.json` file. For example: -Qwen Code loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence. - -Within each location, individual extensions exist as a directory that contains a `qwen-extension.json` file. For example: - -`/.qwen/extensions/my-extension/qwen-extension.json` +`/.qwen/extensions/my-extension/qwen-extension.json` ### `qwen-extension.json` @@ -33,19 +102,20 @@ The `qwen-extension.json` file contains the configuration for the extension. The } ``` -- `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. +- `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. - `mcpServers`: A map of MCP servers to configure. 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 configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. -- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.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 all MCP server configuration options are supported except for `trust`. +- `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 `QWEN.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. When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. -## Extension Commands +### Custom commands Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions. -### Example +**Example** An extension named `gcp` with the following structure: @@ -63,7 +133,7 @@ Would provide these commands: - `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help - `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help -### Conflict Resolution +### Conflict resolution Extension commands have the lowest precedence. When a conflict occurs with user or project commands: @@ -75,20 +145,7 @@ For example, if both a user and the `gcp` extension define a `deploy` command: - `/deploy` - Executes the user's deploy command - `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag) -## Installing Extensions - -You can install extensions using the `install` command. This command allows you to install extensions from a Git repository or a local path. - -### Usage - -`qwen extensions install | [options]` - -### Options - -- `source positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `qwen-extension.json` file in its root. -- `--path `: The path to a local directory to install as an extension. The directory must contain a `qwen-extension.json` file. - -# Variables +## Variables Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`. @@ -97,4 +154,5 @@ Qwen Code extensions allow variable substitution in `qwen-extension.json`. This | variable | description | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. | +| `${workspacePath}` | The fully-qualified path of the current workspace. | | `${/} or ${pathSeparator}` | The path separator (differs per OS). | diff --git a/docs/getting-started-extensions.md b/docs/getting-started-extensions.md new file mode 100644 index 00000000..db4ed35f --- /dev/null +++ b/docs/getting-started-extensions.md @@ -0,0 +1,213 @@ +# Getting Started with Qwen Code Extensions + +This guide will walk you through creating your first Qwen Code extension. You'll learn how to set up a new extension, add a custom tool via an MCP server, create a custom command, and provide context to the model with a `QWEN.md` file. + +## Prerequisites + +Before you start, make sure you have the Qwen Code installed and a basic understanding of Node.js and TypeScript. + +## Step 1: Create a New Extension + +The easiest way to start is by using one of the built-in templates. 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 +qwen extensions new my-first-extension mcp-server +``` + +This will create a new directory with the following structure: + +``` +my-first-extension/ +├── example.ts +├── qwen-extension.json +├── package.json +└── tsconfig.json +``` + +## Step 2: Understand the Extension Files + +Let's look at the key files in your new extension. + +### `qwen-extension.json` + +This is the manifest file for your extension. It tells Qwen Code how to load and use your extension. + +```json +{ + "name": "my-first-extension", + "version": "1.0.0", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}dist${/}example.js"], + "cwd": "${extensionPath}" + } + } +} +``` + +- `name`: The unique name for your extension. +- `version`: The version of your extension. +- `mcpServers`: This section defines one or more Model Context Protocol (MCP) servers. MCP servers are how you can add new tools for the model to use. + - `command`, `args`, `cwd`: These fields specify how to start your server. Notice the use of the `${extensionPath}` variable, which Qwen Code replaces with the absolute path to your extension's installation directory. This allows your extension to work regardless of where it's installed. + +### `example.ts` + +This file contains the source code for your MCP server. It's a simple Node.js server that uses the `@modelcontextprotocol/sdk`. + +```typescript +/** + * @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), + }, + ], + }; + }, +); + +// ... (prompt registration omitted for brevity) + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This server defines a single tool called `fetch_posts` that fetches data from a public API. + +### `package.json` and `tsconfig.json` + +These are standard configuration files for a TypeScript project. The `package.json` file defines dependencies and a `build` script, and `tsconfig.json` configures the TypeScript compiler. + +## Step 3: Build and Link Your Extension + +Before you can use the extension, you need to compile the TypeScript code and link the extension to your Qwen Code installation for local development. + +1. **Install dependencies:** + + ```bash + cd my-first-extension + npm install + ``` + +2. **Build the server:** + + ```bash + npm run build + ``` + + This will compile `example.ts` into `dist/example.js`, which is the file referenced in your `qwen-extension.json`. + +3. **Link the extension:** + + The `link` command creates a symbolic link from the Qwen Code extensions directory to your development directory. This means any changes you make will be reflected immediately without needing to reinstall. + + ```bash + qwen extensions link . + ``` + +Now, restart your Qwen Code session. The new `fetch_posts` tool will be available. You can test it by asking: "fetch posts". + +## Step 4: Add a Custom Command + +Custom commands provide a way to create shortcuts for complex prompts. Let's add a command that searches for a pattern in your code. + +1. Create a `commands` directory and a subdirectory for your command group: + + ```bash + mkdir -p 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`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization. + +After saving the file, restart the Qwen Code. You can now run `/fs:grep-code "some pattern"` to use your new command. + +## Step 5: Add a Custom `QWEN.md` + +You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts. + +1. Create a file named `QWEN.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 `qwen-extension.json` to tell the CLI to load this file: + + ```json + { + "name": "my-first-extension", + "version": "1.0.0", + "contextFileName": "QWEN.md", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}dist${/}example.js"], + "cwd": "${extensionPath}" + } + } + } + ``` + +Restart the CLI again. The model will now have the context from your `QWEN.md` file in every session where the extension is active. + +## Step 6: Releasing Your Extension + +Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method. + +For detailed instructions on both methods, please refer to the [Extension Releasing Guide](extension-releasing.md). + +## Conclusion + +You've successfully created a Qwen Code extension! You learned how to: + +- Bootstrap a new extension from a template. +- Add custom tools with an MCP server. +- Create convenient custom commands. +- Provide persistent context to the model. +- Link your extension for local development. + +From here, you can explore more advanced features and build powerful new capabilities into the Qwen Code. diff --git a/docs/headless.md b/docs/headless.md new file mode 100644 index 00000000..165819df --- /dev/null +++ b/docs/headless.md @@ -0,0 +1,308 @@ +# Headless Mode + +Headless mode allows you to run Qwen Code programmatically from command line +scripts and automation tools without any interactive UI. This is ideal for +scripting, automation, CI/CD pipelines, and building AI-powered tools. + +- [Headless Mode](#headless-mode) + - [Overview](#overview) + - [Basic Usage](#basic-usage) + - [Direct Prompts](#direct-prompts) + - [Stdin Input](#stdin-input) + - [Combining with File Input](#combining-with-file-input) + - [Output Formats](#output-formats) + - [Text Output (Default)](#text-output-default) + - [JSON Output](#json-output) + - [Response Schema](#response-schema) + - [Example Usage](#example-usage) + - [File Redirection](#file-redirection) + - [Configuration Options](#configuration-options) + - [Examples](#examples) + - [Code review](#code-review) + - [Generate commit messages](#generate-commit-messages) + - [API documentation](#api-documentation) + - [Batch code analysis](#batch-code-analysis) + - [Code review](#code-review-1) + - [Log analysis](#log-analysis) + - [Release notes generation](#release-notes-generation) + - [Model and tool usage tracking](#model-and-tool-usage-tracking) + - [Resources](#resources) + +## Overview + +The headless mode provides a headless interface to Qwen Code that: + +- Accepts prompts via command line arguments or stdin +- Returns structured output (text or JSON) +- Supports file redirection and piping +- Enables automation and scripting workflows +- Provides consistent exit codes for error handling + +## Basic Usage + +### Direct Prompts + +Use the `--prompt` (or `-p`) flag to run in headless mode: + +```bash +qwen --prompt "What is machine learning?" +``` + +### Stdin Input + +Pipe input to Qwen Code from your terminal: + +```bash +echo "Explain this code" | qwen +``` + +### Combining with File Input + +Read from files and process with Qwen Code: + +```bash +cat README.md | qwen --prompt "Summarize this documentation" +``` + +## Output Formats + +### Text Output (Default) + +Standard human-readable output: + +```bash +qwen -p "What is the capital of France?" +``` + +Response format: + +``` +The capital of France is Paris. +``` + +### JSON Output + +Returns structured data including response, statistics, and metadata. This +format is ideal for programmatic processing and automation scripts. + +#### Response Schema + +The JSON output follows this high-level structure: + +```json +{ + "response": "string", // The main AI-generated content answering your prompt + "stats": { + // Usage metrics and performance data + "models": { + // Per-model API and token usage statistics + "[model-name]": { + "api": { + /* request counts, errors, latency */ + }, + "tokens": { + /* prompt, response, cached, total counts */ + } + } + }, + "tools": { + // Tool execution statistics + "totalCalls": "number", + "totalSuccess": "number", + "totalFail": "number", + "totalDurationMs": "number", + "totalDecisions": { + /* accept, reject, modify, auto_accept counts */ + }, + "byName": { + /* per-tool detailed stats */ + } + }, + "files": { + // File modification statistics + "totalLinesAdded": "number", + "totalLinesRemoved": "number" + } + }, + "error": { + // Present only when an error occurred + "type": "string", // Error type (e.g., "ApiError", "AuthError") + "message": "string", // Human-readable error description + "code": "number" // Optional error code + } +} +``` + +#### Example Usage + +```bash +qwen -p "What is the capital of France?" --output-format json +``` + +Response: + +```json +{ + "response": "The capital of France is Paris.", + "stats": { + "models": { + "qwen3-coder-plus": { + "api": { + "totalRequests": 2, + "totalErrors": 0, + "totalLatencyMs": 5053 + }, + "tokens": { + "prompt": 24939, + "candidates": 20, + "total": 25113, + "cached": 21263, + "thoughts": 154, + "tool": 0 + } + } + }, + "tools": { + "totalCalls": 1, + "totalSuccess": 1, + "totalFail": 0, + "totalDurationMs": 1881, + "totalDecisions": { + "accept": 0, + "reject": 0, + "modify": 0, + "auto_accept": 1 + }, + "byName": { + "google_web_search": { + "count": 1, + "success": 1, + "fail": 0, + "durationMs": 1881, + "decisions": { + "accept": 0, + "reject": 0, + "modify": 0, + "auto_accept": 1 + } + } + } + }, + "files": { + "totalLinesAdded": 0, + "totalLinesRemoved": 0 + } + } +} +``` + +### File Redirection + +Save output to files or pipe to other commands: + +```bash +# Save to file +qwen -p "Explain Docker" > docker-explanation.txt +qwen -p "Explain Docker" --output-format json > docker-explanation.json + +# Append to file +qwen -p "Add more details" >> docker-explanation.txt + +# Pipe to other tools +qwen -p "What is Kubernetes?" --output-format json | jq '.response' +qwen -p "Explain microservices" | wc -w +qwen -p "List programming languages" | grep -i "python" +``` + +## Configuration Options + +Key command-line options for headless usage: + +| Option | Description | Example | +| ----------------------- | ---------------------------------- | ------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` | +| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | + +For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md). + +## Examples + +#### Code review + +```bash +cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt +``` + +#### Generate commit messages + +```bash +result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json) +echo "$result" | jq -r '.response' +``` + +#### API documentation + +```bash +result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json) +echo "$result" | jq -r '.response' > openapi.json +``` + +#### Batch code analysis + +```bash +for file in src/*.py; do + echo "Analyzing $file..." + result=$(cat "$file" | qwen -p "Find potential bugs and suggest improvements" --output-format json) + echo "$result" | jq -r '.response' > "reports/$(basename "$file").analysis" + echo "Completed analysis for $(basename "$file")" >> reports/progress.log +done +``` + +#### Code review + +```bash +result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json) +echo "$result" | jq -r '.response' > pr-review.json +``` + +#### Log analysis + +```bash +grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt +``` + +#### Release notes generation + +```bash +result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json) +response=$(echo "$result" | jq -r '.response') +echo "$response" +echo "$response" >> CHANGELOG.md +``` + +#### Model and tool usage tracking + +```bash +result=$(qwen -p "Explain this database schema" --include-directories db --output-format json) +total_tokens=$(echo "$result" | jq -r '.stats.models // {} | to_entries | map(.value.tokens.total) | add // 0') +models_used=$(echo "$result" | jq -r '.stats.models // {} | keys | join(", ") | if . == "" then "none" else . end') +tool_calls=$(echo "$result" | jq -r '.stats.tools.totalCalls // 0') +tools_used=$(echo "$result" | jq -r '.stats.tools.byName // {} | keys | join(", ") | if . == "" then "none" else . end') +echo "$(date): $total_tokens tokens, $tool_calls tool calls ($tools_used) used with models: $models_used" >> usage.log +echo "$result" | jq -r '.response' > schema-docs.md +echo "Recent usage trends:" +tail -5 usage.log +``` + +## Resources + +- [CLI Configuration](./cli/configuration.md) - Complete configuration guide +- [Authentication](./cli/authentication.md) - Setup authentication +- [Commands](./cli/commands.md) - Interactive commands reference +- [Tutorials](./cli/tutorials.md) - Step-by-step automation guides diff --git a/docs/ide-companion-spec.md b/docs/ide-companion-spec.md new file mode 100644 index 00000000..3cf35d75 --- /dev/null +++ b/docs/ide-companion-spec.md @@ -0,0 +1,184 @@ +# Qwen Code Companion Plugin: Interface Specification + +> Last Updated: September 15, 2025 + +This document defines the contract for building a companion plugin to enable Qwen Code'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=qwenlm.qwen-code-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 + +Qwen Code 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 Qwen Code 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()/qwen/ide/`. Your plugin must create this directory if it doesn't exist. +- **File Naming Convention:** The filename is critical and **MUST** follow the pattern: + `qwen-code-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 & 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 `QWEN_CODE_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 `QWEN_CODE_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. diff --git a/docs/ide-integration.md b/docs/ide-integration.md index e2040f5d..febcf478 100644 --- a/docs/ide-integration.md +++ b/docs/ide-integration.md @@ -2,7 +2,7 @@ Qwen Code 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 only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. +Currently, the only supported IDE is [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 diff --git a/docs/index.md b/docs/index.md index 37100f92..02e5ce4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,9 @@ This documentation is organized into the following sections: - **[Checkpointing](./checkpointing.md):** Documentation for the checkpointing feature. - **[Extensions](./extension.md):** How to extend the CLI with new functionality. - **[IDE Integration](./ide-integration.md):** Connect the CLI to your editor. + - **[IDE Companion Extension Spec](./ide-companion-spec.md):** Spec for building IDE companion extensions. - **[Telemetry](./telemetry.md):** Overview of telemetry in the CLI. + - **[Trusted Folders](./trusted-folders.md):** An overview of the Trusted Folders security feature. - **Core Details:** Documentation for `packages/core`. - **[Core Introduction](./core/index.md):** Overview of the core component. - **[Tools API](./core/tools-api.md):** Information on how the core manages and exposes tools. diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 00c91fe1..cf163dbb 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -20,7 +20,7 @@ npm run test:e2e ## Running a specific set of tests -To run a subset of test files, you can use `npm run ....` where 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`: +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 diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 792ca88f..7828d5e2 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -16,10 +16,10 @@ Here is a breakdown of the specific automation workflows that run in our reposit 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` +- **Workflow File**: `.github/workflows/qwen-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. + - It uses a Qwen 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. @@ -47,7 +47,7 @@ This workflow ensures that all changes meet our quality standards before they ca 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` +- **Workflow File**: `.github/workflows/qwen-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`). @@ -61,11 +61,11 @@ This workflow runs periodically to ensure all open PRs are correctly linked to i 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` +- **Workflow File**: `.github/workflows/qwen-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. + - It then triggers the same powerful QwenCode-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. diff --git a/docs/mermaid/context.mmd b/docs/mermaid/context.mmd new file mode 100644 index 00000000..ebe4fbee --- /dev/null +++ b/docs/mermaid/context.mmd @@ -0,0 +1,103 @@ +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 diff --git a/docs/mermaid/render-path.mmd b/docs/mermaid/render-path.mmd new file mode 100644 index 00000000..5f4c6204 --- /dev/null +++ b/docs/mermaid/render-path.mmd @@ -0,0 +1,64 @@ +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 + diff --git a/docs/sandbox.md b/docs/sandbox.md index de21621b..f67ddae6 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -55,7 +55,9 @@ qwen -p "run the test suite" # Configure in settings.json { - "sandbox": "docker" + "tools": { + "sandbox": "docker" + } } ``` @@ -65,7 +67,7 @@ qwen -p "run the test suite" 1. **Command flag**: `-s` or `--sandbox` 2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec` -3. **Settings file**: `"sandbox": true` in `settings.json` +3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). ### macOS Seatbelt profiles diff --git a/docs/sidebar.json b/docs/sidebar.json new file mode 100644 index 00000000..b4a75052 --- /dev/null +++ b/docs/sidebar.json @@ -0,0 +1,68 @@ +[ + { + "label": "Overview", + "items": [ + { "label": "Welcome", "slug": "docs" }, + { "label": "Execution and Deployment", "slug": "docs/deployment" }, + { "label": "Architecture Overview", "slug": "docs/architecture" } + ] + }, + { + "label": "CLI", + "items": [ + { "label": "Introduction", "slug": "docs/cli" }, + { "label": "Authentication", "slug": "docs/cli/authentication" }, + { "label": "Commands", "slug": "docs/cli/commands" }, + { "label": "Configuration", "slug": "docs/cli/configuration" }, + { "label": "Checkpointing", "slug": "docs/checkpointing" }, + { "label": "Extensions", "slug": "docs/extension" }, + { "label": "Headless Mode", "slug": "docs/headless" }, + { "label": "IDE Integration", "slug": "docs/ide-integration" }, + { + "label": "IDE Companion Spec", + "slug": "docs/ide-companion-spec" + }, + { "label": "Telemetry", "slug": "docs/telemetry" }, + { "label": "Themes", "slug": "docs/cli/themes" }, + { "label": "Token Caching", "slug": "docs/cli/token-caching" }, + { "label": "Trusted Folders", "slug": "docs/trusted-folders" }, + { "label": "Tutorials", "slug": "docs/cli/tutorials" } + ] + }, + { + "label": "Core", + "items": [ + { "label": "Introduction", "slug": "docs/core" }, + { "label": "Tools API", "slug": "docs/core/tools-api" }, + { "label": "Memory Import Processor", "slug": "docs/core/memport" } + ] + }, + { + "label": "Tools", + "items": [ + { "label": "Overview", "slug": "docs/tools" }, + { "label": "File System", "slug": "docs/tools/file-system" }, + { "label": "Multi-File Read", "slug": "docs/tools/multi-file" }, + { "label": "Shell", "slug": "docs/tools/shell" }, + { "label": "Web Fetch", "slug": "docs/tools/web-fetch" }, + { "label": "Web Search", "slug": "docs/tools/web-search" }, + { "label": "Memory", "slug": "docs/tools/memory" }, + { "label": "MCP Servers", "slug": "docs/tools/mcp-server" }, + { "label": "Sandboxing", "slug": "docs/sandbox" } + ] + }, + { + "label": "Development", + "items": [ + { "label": "NPM", "slug": "docs/npm" }, + { "label": "Releases", "slug": "docs/releases" } + ] + }, + { + "label": "Support", + "items": [ + { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, + { "label": "Terms of Service", "slug": "docs/tos-privacy" } + ] + } +] diff --git a/docs/telemetry.md b/docs/telemetry.md index 3d6df75e..5ea185a3 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,158 +1,206 @@ -# Qwen Code Observability Guide +# Observability with OpenTelemetry -Telemetry provides data about Qwen Code's performance, health, and usage. By enabling it, you can monitor operations, debug issues, and optimize tool usage through traces, metrics, and structured logs. +Learn how to enable and setup OpenTelemetry for Qwen Code. -Qwen Code's telemetry system is built on the **[OpenTelemetry] (OTEL)** standard, allowing you to send data to any compatible backend. +- [Observability with OpenTelemetry](#observability-with-opentelemetry) + - [Key Benefits](#key-benefits) + - [OpenTelemetry Integration](#opentelemetry-integration) + - [Configuration](#configuration) + - [Google Cloud Telemetry](#google-cloud-telemetry) + - [Prerequisites](#prerequisites) + - [Direct Export (Recommended)](#direct-export-recommended) + - [Collector-Based Export (Advanced)](#collector-based-export-advanced) + - [Local Telemetry](#local-telemetry) + - [File-based Output (Recommended)](#file-based-output-recommended) + - [Collector-Based Export (Advanced)](#collector-based-export-advanced-1) + - [Logs and Metrics](#logs-and-metrics) + - [Logs](#logs) + - [Metrics](#metrics) + +## Key Benefits + +- **🔍 Usage Analytics**: Understand interaction patterns and feature adoption + across your team +- **⚡ Performance Monitoring**: Track response times, token consumption, and + resource utilization +- **🐛 Real-time Debugging**: Identify bottlenecks, failures, and error patterns + as they occur +- **📊 Workflow Optimization**: Make informed decisions to improve + configurations and processes +- **🏢 Enterprise Governance**: Monitor usage across teams, track costs, ensure + compliance, and integrate with existing monitoring infrastructure + +## OpenTelemetry Integration + +Built on **[OpenTelemetry]** — the vendor-neutral, industry-standard +observability framework — Qwen Code's 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/ -## Enabling telemetry +## Configuration -You can enable telemetry in multiple ways. Configuration is primarily managed via the [`.qwen/settings.json` file](./cli/configuration.md) and environment variables, but CLI flags can override these settings for a specific session. +All telemetry behavior is controlled through your `.qwen/settings.json` file. +These settings can be overridden by environment variables or CLI flags. -### Order of precedence +| Setting | Environment Variable | CLI Flag | Description | Values | Default | +| -------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- | +| `enabled` | `GEMINI_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` | +| `target` | `GEMINI_TELEMETRY_TARGET` | `--telemetry-target ` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | +| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | URL string | `http://localhost:4317` | +| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol ` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | +| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | +| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | +| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` | -The following lists the precedence for applying telemetry settings, with items listed higher having greater precedence: +**Note on boolean environment variables:** For the boolean settings (`enabled`, +`logPrompts`, `useCollector`), setting the corresponding environment variable to +`true` or `1` will enable the feature. Any other value will disable it. -1. **CLI flags (for `qwen` command):** - - `--telemetry` / `--no-telemetry`: Overrides `telemetry.enabled`. - - `--telemetry-target `: Overrides `telemetry.target`. - - `--telemetry-otlp-endpoint `: Overrides `telemetry.otlpEndpoint`. - - `--telemetry-log-prompts` / `--no-telemetry-log-prompts`: Overrides `telemetry.logPrompts`. - - `--telemetry-outfile `: Redirects telemetry output to a file. See [Exporting to a file](#exporting-to-a-file). +For detailed information about all configuration options, see the +[Configuration Guide](./cli/configuration.md). -1. **Environment variables:** - - `OTEL_EXPORTER_OTLP_ENDPOINT`: Overrides `telemetry.otlpEndpoint`. +## Google Cloud Telemetry -1. **Workspace settings file (`.qwen/settings.json`):** Values from the `telemetry` object in this project-specific file. +### Prerequisites -1. **User settings file (`~/.qwen/settings.json`):** Values from the `telemetry` object in this global user file. +Before using either method below, complete these steps: -1. **Defaults:** applied if not set by any of the above. - - `telemetry.enabled`: `false` - - `telemetry.target`: `local` - - `telemetry.otlpEndpoint`: `http://localhost:4317` - - `telemetry.logPrompts`: `true` +1. Set your Google Cloud project ID: + - For telemetry in a separate project from inference: + ```bash + export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` + - For telemetry in the same project as inference: + ```bash + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` -**For the `npm run telemetry -- --target=` script:** -The `--target` argument to this script _only_ overrides the `telemetry.target` for the duration and purpose of that script (i.e., choosing which collector to start). It does not permanently change your `settings.json`. The script will first look at `settings.json` for a `telemetry.target` to use as its default. +2. Authenticate with Google Cloud: + - If using a user account: + ```bash + gcloud auth application-default login + ``` + - If using a service account: + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" + ``` +3. Make sure your account or service account has these IAM roles: + - Cloud Trace Agent + - Monitoring Metric Writer + - Logs Writer -### Example settings +4. Enable the required Google Cloud APIs (if not already enabled): + ```bash + gcloud services enable \ + cloudtrace.googleapis.com \ + monitoring.googleapis.com \ + logging.googleapis.com \ + --project="$OTLP_GOOGLE_CLOUD_PROJECT" + ``` -The following code can be added to your workspace (`.qwen/settings.json`) or user (`~/.qwen/settings.json`) settings to enable telemetry and send the output to Google Cloud: +### Direct Export (Recommended) -```json -{ - "telemetry": { - "enabled": true, - "target": "gcp" - }, - "sandbox": false -} -``` +Sends telemetry directly to Google Cloud services. No collector needed. -### Exporting to a file +1. Enable telemetry in your `.qwen/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + ``` +2. Run Qwen Code and send prompts. +3. View logs and metrics: + - Open the Google Cloud Console in your browser after sending prompts: + - Logs: https://console.cloud.google.com/logs/ + - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer + - Traces: https://console.cloud.google.com/traces/list -You can export all telemetry data to a file for local inspection. +### Collector-Based Export (Advanced) -To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`. +For custom processing, filtering, or routing, use an OpenTelemetry collector to +forward data to Google Cloud. -```bash -# Set your desired output file path -TELEMETRY_FILE=".qwen/telemetry.log" +1. Configure your `.qwen/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCollector": true + } + } + ``` +2. Run the automation script: + ```bash + npm run telemetry -- --target=gcp + ``` + This will: + - Start a local OTEL collector that forwards to Google Cloud + - Configure your workspace + - Provide links to view traces, metrics, and logs in Google Cloud Console + - Save collector logs to `~/.qwen/tmp//otel/collector-gcp.log` + - Stop collector on exit (e.g. `Ctrl+C`) +3. Run Qwen Code and send prompts. +4. View logs and metrics: + - Open the Google Cloud Console in your browser after sending prompts: + - Logs: https://console.cloud.google.com/logs/ + - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer + - Traces: https://console.cloud.google.com/traces/list + - Open `~/.qwen/tmp//otel/collector-gcp.log` to view local + collector logs. -# Run Qwen Code with local telemetry -# NOTE: --telemetry-otlp-endpoint="" is required to override the default -# OTLP exporter and ensure telemetry is written to the local file. -qwen --telemetry \ - --telemetry-target=local \ - --telemetry-otlp-endpoint="" \ - --telemetry-outfile="$TELEMETRY_FILE" \ - --prompt "What is OpenTelemetry?" -``` +## Local Telemetry -## Running an OTEL Collector +For local development and debugging, you can capture telemetry data locally: -An OTEL Collector is a service that receives, processes, and exports telemetry data. -The CLI can send data using either the OTLP/gRPC or OTLP/HTTP protocol. -You can specify which protocol to use via the `--telemetry-otlp-protocol` flag -or the `telemetry.otlpProtocol` setting in your `settings.json` file. See the -[configuration docs](./cli/configuration.md#--telemetry-otlp-protocol) for more -details. +### File-based Output (Recommended) -Learn more about OTEL exporter standard configuration in [documentation][otel-config-docs]. +1. Enable telemetry in your `.qwen/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "", + "outfile": ".qwen/telemetry.log" + } + } + ``` +2. Run Qwen Code and send prompts. +3. View logs and metrics in the specified file (e.g., `.qwen/telemetry.log`). -[otel-config-docs]: https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ +### Collector-Based Export (Advanced) -### Local +1. Run the automation script: + ```bash + npm run telemetry -- --target=local + ``` + This will: + - Download and start Jaeger and OTEL collector + - Configure your workspace for local telemetry + - Provide a Jaeger UI at http://localhost:16686 + - Save logs/metrics to `~/.qwen/tmp//otel/collector.log` + - Stop collector on exit (e.g. `Ctrl+C`) +2. Run Qwen Code and send prompts. +3. View traces at http://localhost:16686 and logs/metrics in the collector log + file. -Use the `npm run telemetry -- --target=local` command to automate the process of setting up a local telemetry pipeline, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib` (the OpenTelemetry Collector) and `jaeger` (The Jaeger UI for viewing traces). To use it: +## Logs and Metrics -1. **Run the command**: - Execute the command from the root of the repository: - - ```bash - npm run telemetry -- --target=local - ``` - - The script will: - - Download Jaeger and OTEL if needed. - - Start a local Jaeger instance. - - Start an OTEL collector configured to receive data from Qwen Code. - - Automatically enable telemetry in your workspace settings. - - On exit, disable telemetry. - -1. **View traces**: - Open your web browser and navigate to **http://localhost:16686** to access the Jaeger UI. Here you can inspect detailed traces of Qwen Code operations. - -1. **Inspect logs and metrics**: - The script redirects the OTEL collector output (which includes logs and metrics) to `~/.qwen/tmp//otel/collector.log`. The script will provide links to view and a command to tail your telemetry data (traces, metrics, logs) locally. - -1. **Stop the services**: - Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector and Jaeger services. - -### Google Cloud - -Use the `npm run telemetry -- --target=gcp` command to automate setting up a local OpenTelemetry collector that forwards data to your Google Cloud project, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib`. To use it: - -1. **Prerequisites**: - - Have a Google Cloud project ID. - - Export the `GOOGLE_CLOUD_PROJECT` environment variable to make it available to the OTEL collector. - ```bash - export OTLP_GOOGLE_CLOUD_PROJECT="your-project-id" - ``` - - Authenticate with Google Cloud (e.g., run `gcloud auth application-default login` or ensure `GOOGLE_APPLICATION_CREDENTIALS` is set). - - Ensure your Google Cloud account/service account has the necessary IAM roles: "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer". - -1. **Run the command**: - Execute the command from the root of the repository: - - ```bash - npm run telemetry -- --target=gcp - ``` - - The script will: - - Download the `otelcol-contrib` binary if needed. - - Start an OTEL collector configured to receive data from Qwen Code and export it to your specified Google Cloud project. - - Automatically enable telemetry and disable sandbox mode in your workspace settings (`.qwen/settings.json`). - - Provide direct links to view traces, metrics, and logs in your Google Cloud Console. - - On exit (Ctrl+C), it will attempt to restore your original telemetry and sandbox settings. - -1. **Run Qwen Code:** - In a separate terminal, run your Qwen Code commands. This generates telemetry data that the collector captures. - -1. **View telemetry in Google Cloud**: - Use the links provided by the script to navigate to the Google Cloud Console and view your traces, metrics, and logs. - -1. **Inspect local collector logs**: - The script redirects the local OTEL collector output to `~/.qwen/tmp//otel/collector-gcp.log`. The script provides links to view and command to tail your collector logs locally. - -1. **Stop the service**: - Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector. - -## Logs and metric reference - -The following section describes the structure of logs and metrics generated for Qwen Code. +The following section describes the structure of logs and metrics generated for +Qwen Code. - A `sessionId` is included as a common attribute on all logs and metrics. @@ -174,12 +222,14 @@ Logs are timestamped records of specific events. The following events are logged - `file_filtering_respect_git_ignore` (boolean) - `debug_mode` (boolean) - `mcp_servers` (string) + - `output_format` (string: "text" or "json") - `qwen-code.user_prompt`: This event occurs when a user submits a prompt. - **Attributes**: - `prompt_length` (int) - `prompt_id` (string) - - `prompt` (string, this attribute is excluded if `log_prompts_enabled` is configured to be `false`) + - `prompt` (string, this attribute is excluded if `log_prompts_enabled` is + configured to be `false`) - `auth_type` (string) - `qwen-code.tool_call`: This event occurs for each function call. @@ -188,11 +238,27 @@ Logs are timestamped records of specific events. The following events are logged - `function_args` - `duration_ms` - `success` (boolean) - - `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable) + - `decision` (string: "accept", "reject", "auto_accept", or "modify", if + applicable) - `error` (if applicable) - `error_type` (if applicable) + - `content_length` (int, if applicable) - `metadata` (if applicable, dictionary of string -> any) +- `qwen-code.file_operation`: This event occurs for each file operation. + - **Attributes**: + - `tool_name` (string) + - `operation` (string: "create", "read", "update") + - `lines` (int, if applicable) + - `mimetype` (string, if applicable) + - `extension` (string, if applicable) + - `programming_language` (string, if applicable) + - `diff_stat` (json string, if applicable): A JSON string with the following members: + - `ai_added_lines` (int) + - `ai_removed_lines` (int) + - `user_added_lines` (int) + - `user_removed_lines` (int) + - `qwen-code.api_request`: This event occurs when making a request to Qwen API. - **Attributes**: - `model` @@ -221,6 +287,19 @@ Logs are timestamped records of specific events. The following events are logged - `response_text` (if applicable) - `auth_type` +- `qwen-code.tool_output_truncated`: This event occurs when the output of a tool call is too large and gets truncated. + - **Attributes**: + - `tool_name` (string) + - `original_content_length` (int) + - `truncated_content_length` (int) + - `threshold` (int) + - `lines` (int) + - `prompt_id` (string) + +- `qwen-code.malformed_json_response`: This event occurs when a `generateJson` response from Qwen API cannot be parsed as a json. + - **Attributes**: + - `model` + - `qwen-code.flash_fallback`: This event occurs when Qwen Code switches to flash as fallback. - **Attributes**: - `auth_type` @@ -230,6 +309,15 @@ Logs are timestamped records of specific events. The following events are logged - `command` (string) - `subcommand` (string, if applicable) +- `qwen-code.extension_enable`: This event occurs when an extension is enabled +- `qwen-code.extension_install`: This event occurs when an extension is installed + - **Attributes**: + - `extension_name` (string) + - `extension_version` (string) + - `extension_source` (string) + - `status` (string) +- `qwen-code.extension_uninstall`: This event occurs when an extension is uninstalled + ### Metrics Metrics are numerical measurements of behavior over time. The following metrics are collected for Qwen Code (metric names remain `qwen-code.*` for compatibility): @@ -269,8 +357,8 @@ Metrics are numerical measurements of behavior over time. The following metrics - `lines` (Int, if applicable): Number of lines in the file. - `mimetype` (string, if applicable): Mimetype of the file. - `extension` (string, if applicable): File extension of the file. - - `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI. - - `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI. + - `model_added_lines` (Int, if applicable): Number of lines added/changed by the model. + - `model_removed_lines` (Int, if applicable): Number of lines removed/changed by the model. - `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes. - `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes. - `programming_language` (string, if applicable): The programming language of the file. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 07cea5e3..4a75b1fc 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -51,7 +51,30 @@ Qwen Code uses the `mcpServers` configuration in your `settings.json` file to lo ### Configure the MCP server in settings.json -You can configure MCP servers at the global level in the `~/.qwen/settings.json` file or in your project's root directory, create or open the `.qwen/settings.json` file. Within the file, add the `mcpServers` configuration block. +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` allows you to 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 @@ -92,8 +115,10 @@ Each server configuration supports the following properties: - **`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 (whitelist behavior). If not specified, all tools from the server are enabled by default. +- **`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. **Note:** `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'`. ### OAuth Support for Remote MCP Servers @@ -187,6 +212,9 @@ You can specify the authentication provider type using the `authProviderType` pr - **`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 { @@ -202,6 +230,24 @@ You can specify the authentication provider type using the `authProviderType` pr } ``` +#### 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 allowslisted 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) @@ -310,6 +356,21 @@ You can specify the authentication provider type using the `authProviderType` pr } ``` +### 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 Qwen Code starts, it performs MCP server discovery through the following detailed process: @@ -667,9 +728,13 @@ await server.connect(transport); This can be included in `settings.json` under `mcpServers` with: ```json -"nodeServer": { - "command": "node", - "args": ["filename.ts"], +{ + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["filename.ts"] + } + } } ``` diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 9bd82a39..8113a989 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -4,7 +4,9 @@ This document describes the `run_shell_command` tool for Qwen Code. ## Description -Use `run_shell_command` to interact with the underlying system, run scripts, or perform command-line operations. `run_shell_command` executes a given shell command. On Windows, the command will be executed with `cmd.exe /c`. On other platforms, the command will be executed with `bash -c`. +Use `run_shell_command` to interact with the underlying system, run scripts, or perform command-line operations. `run_shell_command` executes a given shell command, including interactive commands that require user input (e.g., `vim`, `git rebase -i`) if the `tools.shell.enableInteractiveShell` setting is set to `true`. + +On Windows, commands are executed with `cmd.exe /c`. On other platforms, they are executed with `bash -c`. ### Arguments @@ -102,10 +104,67 @@ Start multiple background services: run_shell_command(command="docker-compose up", description="Start all services", is_background=true) ``` +## 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 Qwen Code. + +### 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`. **Note: 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`. **Note: 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 Qwen Code. To focus on the interactive shell, press `ctrl+f`. 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. -- **Interactive commands:** Avoid commands that require interactive user input, as this can cause the tool to hang. Use non-interactive flags if available (e.g., `npm init -y`). - **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully. - **Background processes:** When `is_background=true` or when a command contains `&`, 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. - **Background execution choices:** The `is_background` parameter is required and provides explicit control over execution mode. You can also add `&` to the command for manual background execution, but the `is_background` parameter must still be specified. The parameter provides clearer intent and automatically handles the background execution setup. @@ -117,16 +176,16 @@ When `run_shell_command` executes a command, it sets the `QWEN_CODE=1` environme ## Command Restrictions -You can restrict the commands that can be executed by the `run_shell_command` tool by using the `coreTools` and `excludeTools` settings in your configuration file. +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. -- `coreTools`: To restrict `run_shell_command` to a specific set of commands, add entries to the `coreTools` list in the format `run_shell_command()`. For example, `"coreTools": ["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. -- `excludeTools`: To block specific commands, add entries to the `excludeTools` list in the format `run_shell_command()`. For example, `"excludeTools": ["run_shell_command(rm)"]` will block `rm` commands. +- `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`: To block specific commands, add 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 `excludeTools` 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 `coreTools`. +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 @@ -136,7 +195,9 @@ To allow only `git` and `npm` commands, and block all others: ```json { - "coreTools": ["run_shell_command(git)", "run_shell_command(npm)"] + "tools": { + "core": ["run_shell_command(git)", "run_shell_command(npm)"] + } } ``` @@ -150,8 +211,10 @@ To block `rm` and allow all other commands: ```json { - "coreTools": ["run_shell_command"], - "excludeTools": ["run_shell_command(rm)"] + "tools": { + "core": ["run_shell_command"], + "exclude": ["run_shell_command(rm)"] + } } ``` @@ -161,12 +224,14 @@ To block `rm` and allow all other commands: **Blocklist takes precedence** -If a command prefix is in both `coreTools` and `excludeTools`, it will be blocked. +If a command prefix is in both `tools.core` and `tools.exclude`, it will be blocked. ```json { - "coreTools": ["run_shell_command(git)"], - "excludeTools": ["run_shell_command(git push)"] + "tools": { + "core": ["run_shell_command(git)"], + "exclude": ["run_shell_command(git push)"] + } } ``` @@ -175,11 +240,13 @@ If a command prefix is in both `coreTools` and `excludeTools`, it will be blocke **Block all shell commands** -To block all shell commands, add the `run_shell_command` wildcard to `excludeTools`: +To block all shell commands, add the `run_shell_command` wildcard to `tools.exclude`: ```json { - "excludeTools": ["run_shell_command"] + "tools": { + "exclude": ["run_shell_command"] + } } ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5950fc97..f53c25ec 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -62,7 +62,7 @@ This guide provides solutions to common issues and debugging tips, including top - **DEBUG mode not working from project .env file** - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for the CLI. - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with the CLI behavior. - - **Solution:** Use a `.qwen/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables. + - **Solution:** Use a `.qwen/.env` file instead, or configure the `advanced.excludedEnvVars` setting in your `settings.json` to exclude fewer variables. ## IDE Companion not connecting diff --git a/docs/trusted-folders.md b/docs/trusted-folders.md new file mode 100644 index 00000000..6fe3486b --- /dev/null +++ b/docs/trusted-folders.md @@ -0,0 +1,61 @@ +# Trusted Folders + +The Trusted Folders feature is a security setting that gives you control over which projects can use the full capabilities of the Qwen Code. 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 Qwen Code 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 (`~/.qwen/trustedFolders.json`), so you will only be asked once per folder. + +## Why Trust Matters: The Impact of an Untrusted Workspace + +When a folder is **untrusted**, the Qwen Code 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 `.qwen/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. + +Granting trust to a folder unlocks the full functionality of the Qwen Code 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 `~/.qwen/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.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 `~/.qwen/trustedFolders.json` file. diff --git a/esbuild.config.js b/esbuild.config.js index 89f9197e..3f1644a6 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -4,16 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import esbuild from 'esbuild'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; + +let esbuild; +try { + esbuild = (await import('esbuild')).default; +} catch (_error) { + console.warn('esbuild not available, skipping bundle step'); + process.exit(0); +} 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')); +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', +]; + esbuild .build({ entryPoints: ['packages/cli/index.ts'], @@ -21,15 +39,7 @@ esbuild outfile: 'bundle/gemini.js', platform: 'node', format: 'esm', - 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', - ], + external, alias: { 'is-in-ci': path.resolve( __dirname, @@ -43,5 +53,12 @@ esbuild js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`, }, loader: { '.node': 'file' }, + metafile: true, + write: true, + }) + .then(({ metafile }) => { + if (process.env.DEV === 'true') { + writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2)); + } }) .catch(() => process.exit(1)); diff --git a/eslint.config.js b/eslint.config.js index d49c504a..e9b17327 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,7 @@ export default tseslint.config( 'bundle/**', 'package/bundle/**', '.integration-tests/**', + 'dist/**', ], }, eslint.configs.recommended, diff --git a/hello/QWEN.md b/hello/QWEN.md new file mode 100644 index 00000000..22f6bbce --- /dev/null +++ b/hello/QWEN.md @@ -0,0 +1,8 @@ +# Ink Library Screen Reader Guidance + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +## General Principles + +Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/hello/qwen-extension.json b/hello/qwen-extension.json new file mode 100644 index 00000000..9e0e4e89 --- /dev/null +++ b/hello/qwen-extension.json @@ -0,0 +1,5 @@ +{ + "name": "context-example", + "version": "1.0.0", + "contextFileName": "QWEN.md" +} diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts new file mode 100644 index 00000000..ddfa6839 --- /dev/null +++ b/integration-tests/context-compress-interactive.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig, type } from './test-helper.js'; + +describe('Interactive Mode', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it.skipIf(process.platform === 'win32')( + 'should trigger chat compression with /compress command', + async () => { + await rig.setup('interactive-compress-test'); + + const { ptyProcess } = rig.runInteractive(); + + let fullOutput = ''; + ptyProcess.onData((data) => (fullOutput += data)); + + const authDialogAppeared = await rig.waitForText( + 'How would you like to authenticate', + 5000, + ); + + // select the second option if auth dialog come's up + if (authDialogAppeared) { + ptyProcess.write('2'); + } + + // Wait for the app to be ready + const isReady = await rig.waitForText('Type your message', 15000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); + + const longPrompt = + 'Dont do anything except returning a 1000 token long paragragh with the at the end to indicate end of response. This is a moderately long sentence.'; + + await type(ptyProcess, longPrompt); + await type(ptyProcess, '\r'); + + await rig.waitForText('einstein', 25000); + + await type(ptyProcess, '/compress'); + // A small delay to allow React to re-render the command list. + await new Promise((resolve) => setTimeout(resolve, 100)); + await type(ptyProcess, '\r'); + + const foundEvent = await rig.waitForTelemetryEvent( + 'chat_compression', + 90000, + ); + expect(foundEvent, 'chat_compression telemetry event was not found').toBe( + true, + ); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'should handle compression failure on token inflation', + async () => { + await rig.setup('interactive-compress-test'); + + const { ptyProcess } = rig.runInteractive(); + + let fullOutput = ''; + ptyProcess.onData((data) => (fullOutput += data)); + + const authDialogAppeared = await rig.waitForText( + 'How would you like to authenticate', + 5000, + ); + + // select the second option if auth dialog come's up + if (authDialogAppeared) { + ptyProcess.write('2'); + } + + // Wait for the app to be ready + const isReady = await rig.waitForText('Type your message', 25000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); + + await type(ptyProcess, '/compress'); + await new Promise((resolve) => setTimeout(resolve, 100)); + await type(ptyProcess, '\r'); + + const foundEvent = await rig.waitForTelemetryEvent( + 'chat_compression', + 90000, + ); + expect(foundEvent).toBe(true); + + const compressionFailed = await rig.waitForText( + 'compression was not beneficial', + 25000, + ); + + expect(compressionFailed).toBe(true); + }, + ); +}); diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/ctrl-c-exit.test.ts new file mode 100644 index 00000000..bc89d045 --- /dev/null +++ b/integration-tests/ctrl-c-exit.test.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { TestRig } from './test-helper.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +describe('Ctrl+C exit', () => { + // (#9782) Temporarily disabling on windows because it is failing on main and every + // PR, which is potentially hiding other failures + it.skipIf(process.platform === 'win32')( + 'should exit gracefully on second Ctrl+C', + async () => { + const rig = new TestRig(); + await rig.setup('should exit gracefully on second Ctrl+C'); + + const { ptyProcess, promise } = rig.runInteractive(); + + let output = ''; + ptyProcess.onData((data) => { + output += data; + }); + + // Wait for the app to be ready by looking for the initial prompt indicator + await rig.poll(() => output.includes('▶'), 5000, 100); + + // Send first Ctrl+C + ptyProcess.write(String.fromCharCode(3)); + + // Wait for the exit prompt + await rig.poll( + () => output.includes('Press Ctrl+C again to exit'), + 1500, + 50, + ); + + // Send second Ctrl+C + ptyProcess.write(String.fromCharCode(3)); + + const result = await promise; + + // Expect a graceful exit (code 0) + expect( + result.exitCode, + `Process exited with code ${result.exitCode}. Output: ${result.output}`, + ).toBe(0); + + // Check that the quitting message is displayed + const quittingMessage = 'Agent powering down. Goodbye!'; + // The regex below is intentionally matching the ESC control character (\x1b) + // to strip ANSI color codes from the terminal output. + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); + expect(cleanOutput).toContain(quittingMessage); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'should exit gracefully on second Ctrl+C when calling a tool', + async () => { + const rig = new TestRig(); + await rig.setup( + 'should exit gracefully on second Ctrl+C when calling a tool', + ); + + const childProcessFile = 'child_process_file.txt'; + rig.createFile( + 'wait.js', + `setTimeout(() => require('fs').writeFileSync('${childProcessFile}', 'done'), 5000)`, + ); + + const { ptyProcess, promise } = rig.runInteractive(); + + let output = ''; + ptyProcess.onData((data) => { + output += data; + }); + + // Wait for the app to be ready by looking for the initial prompt indicator + await rig.poll(() => output.includes('▶'), 5000, 100); + + ptyProcess.write('use the tool to run "node -e wait.js"\n'); + + await rig.poll(() => output.includes('Shell'), 5000, 100); + + // Send first Ctrl+C + ptyProcess.write(String.fromCharCode(3)); + + // Wait for the exit prompt + await rig.poll( + () => output.includes('Press Ctrl+C again to exit'), + 1500, + 50, + ); + + // Send second Ctrl+C + ptyProcess.write(String.fromCharCode(3)); + + const result = await promise; + + // Expect a graceful exit (code 0) + expect( + result.exitCode, + `Process exited with code ${result.exitCode}. Output: ${result.output}`, + ).toBe(0); + + // Check that the quitting message is displayed + const quittingMessage = 'Agent powering down. Goodbye!'; + // The regex below is intentionally matching the ESC control character (\x1b) + // to strip ANSI color codes from the terminal output. + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); + expect(cleanOutput).toContain(quittingMessage); + + // Check that the child process was terminated and did not create the file. + const childProcessFileExists = fs.existsSync( + path.join(rig.testDir!, childProcessFile), + ); + expect( + childProcessFileExists, + 'Child process file should not exist', + ).toBe(false); + }, + ); +}); diff --git a/integration-tests/edit.test.ts b/integration-tests/edit.test.ts index e8f4f3bf..f53da3f3 100644 --- a/integration-tests/edit.test.ts +++ b/integration-tests/edit.test.ts @@ -61,4 +61,125 @@ describe('edit', () => { console.log('File edited successfully. New content:', newFileContent); } }); + + it('should handle $ literally when replacing text ending with $', async () => { + const rig = new TestRig(); + await rig.setup( + 'should handle $ literally when replacing text ending with $', + ); + + 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); + + const prompt = + "Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character."; + + const result = await rig.run(prompt); + const foundToolCall = await rig.waitForToolCall('edit'); + + if (!foundToolCall) { + printDebugInfo(rig, result); + } + + expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy(); + + validateModelOutput(result, ['regex.yml'], 'Replace $ literal test'); + + const newFileContent = rig.readFile(fileName); + expect(newFileContent).toBe(expectedContent); + }); + + it('should fail safely when old_string is not found', async () => { + const rig = new TestRig(); + await rig.setup('should fail safely when old_string is not found'); + const fileName = 'no_match.txt'; + const fileContent = 'hello world'; + rig.createFile(fileName, fileContent); + + const prompt = `replace "goodbye" with "farewell" in ${fileName}`; + await rig.run(prompt); + + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + const editAttempt = toolLogs.find((log) => log.toolRequest.name === 'edit'); + const readAttempt = toolLogs.find( + (log) => log.toolRequest.name === 'read_file', + ); + + // VERIFY: The model must have at least tried to read the file or perform an edit. + expect( + readAttempt || editAttempt, + 'Expected model to attempt a read_file or edit', + ).toBeDefined(); + + // If the model tried to edit, that specific attempt must have failed. + if (editAttempt) { + if (editAttempt.toolRequest.success) { + console.error('The edit tool succeeded when it was expected to fail'); + console.error('Tool call args:', editAttempt.toolRequest.args); + } + expect( + editAttempt.toolRequest.success, + 'If edit is called, it must fail', + ).toBe(false); + } + + // CRITICAL: The final content of the file must be unchanged. + const newFileContent = rig.readFile(fileName); + expect(newFileContent).toBe(fileContent); + }); + + it('should insert a multi-line block of text', async () => { + const rig = new TestRig(); + await rig.setup('should insert a multi-line block of text'); + const fileName = 'insert_block.js'; + const originalContent = 'function hello() {\n // INSERT_CODE_HERE\n}'; + const newBlock = "console.log('hello');\n console.log('world');"; + const expectedContent = `function hello() {\n ${newBlock}\n}`; + rig.createFile(fileName, originalContent); + + const prompt = `In ${fileName}, replace "// INSERT_CODE_HERE" with:\n${newBlock}`; + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('edit'); + if (!foundToolCall) { + printDebugInfo(rig, result); + } + expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy(); + + const newFileContent = rig.readFile(fileName); + + expect(newFileContent.replace(/\r\n/g, '\n')).toBe( + expectedContent.replace(/\r\n/g, '\n'), + ); + }); + + it('should delete a block of text', async () => { + const rig = new TestRig(); + await rig.setup('should delete a block of text'); + 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`; + // When deleting the block, a newline remains from the original structure (Hello\n + \nWorld) + rig.createFile(fileName, originalContent); + + const prompt = `In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers.`; + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('edit'); + if (!foundToolCall) { + printDebugInfo(rig, result); + } + expect(foundToolCall, 'Expected to find an edit tool call').toBeTruthy(); + + const newFileContent = rig.readFile(fileName); + + // Accept either 1 or 2 newlines between Hello and World + expect(newFileContent).toMatch(/^Hello\n\n?World$/); + }); }); diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts new file mode 100644 index 00000000..935d0ac5 --- /dev/null +++ b/integration-tests/extensions-install.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, test } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const extension = `{ + "name": "test-extension", + "version": "0.0.1" +}`; + +const extensionUpdate = `{ + "name": "test-extension", + "version": "0.0.2" +}`; + +test('installs a local extension, verifies a command, and updates it', async () => { + const rig = new TestRig(); + rig.setup('extension install test'); + const testServerPath = join(rig.testDir!, 'qwen-extension.json'); + writeFileSync(testServerPath, extension); + 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'); + + const listResult = await rig.runCommand(['extensions', 'list']); + expect(listResult).toContain('test-extension'); + writeFileSync(testServerPath, extensionUpdate); + const updateResult = await rig.runCommand([ + 'extensions', + 'update', + `test-extension`, + ]); + expect(updateResult).toContain('0.0.2'); + + await rig.runCommand(['extensions', 'uninstall', 'test-extension']); + + await rig.cleanup(); +}); diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/file-system-interactive.test.ts new file mode 100644 index 00000000..7509afb3 --- /dev/null +++ b/integration-tests/file-system-interactive.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig, type, printDebugInfo } from './test-helper.js'; + +describe('Interactive file system', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it.skipIf(process.platform === 'win32')( + 'should perform a read-then-write sequence', + async () => { + const fileName = 'version.txt'; + await rig.setup('interactive-read-then-write'); + rig.createFile(fileName, '1.0.0'); + + const { ptyProcess } = rig.runInteractive(); + + const authDialogAppeared = await rig.waitForText( + 'How would you like to authenticate', + 5000, + ); + + // select the second option if auth dialog come's up + if (authDialogAppeared) { + ptyProcess.write('2'); + } + + // Wait for the app to be ready + const isReady = await rig.waitForText('Type your message', 15000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); + + // Step 1: Read the file + const readPrompt = `Read the version from ${fileName}`; + await type(ptyProcess, readPrompt); + await type(ptyProcess, '\r'); + + const readCall = await rig.waitForToolCall('read_file', 30000); + expect(readCall, 'Expected to find a read_file tool call').toBe(true); + + const containsExpectedVersion = await rig.waitForText('1.0.0', 15000); + expect( + containsExpectedVersion, + 'Expected to see version "1.0.0" in output', + ).toBe(true); + + // Step 2: Write the file + const writePrompt = `now change the version to 1.0.1 in the file`; + await type(ptyProcess, writePrompt); + await type(ptyProcess, '\r'); + + const toolCall = await rig.waitForAnyToolCall( + ['write_file', 'edit'], + 30000, + ); + + if (!toolCall) { + printDebugInfo(rig, rig._interactiveOutput, { + toolCall, + }); + } + + expect(toolCall, 'Expected to find a write_file or edit tool call').toBe( + true, + ); + + const newFileContent = rig.readFile(fileName); + expect(newFileContent).toBe('1.0.1'); + }, + ); +}); diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index fb6b4591..bd15c76e 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -5,6 +5,8 @@ */ import { describe, it, expect } from 'vitest'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; describe('file-system', () => { @@ -86,4 +88,169 @@ describe('file-system', () => { console.log('File written successfully with hello message.'); } }); + + it('should correctly handle file paths with spaces', async () => { + const rig = new TestRig(); + await rig.setup('should correctly handle file paths with spaces'); + const fileName = 'my test file.txt'; + + const result = await rig.run(`write "hello" to "${fileName}"`); + + 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 () => { + const rig = new TestRig(); + await rig.setup('should perform a read-then-write sequence'); + 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(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 () => { + const rig = new TestRig(); + await 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( + `replace "test line" with "new line" in ${fileName}`, + ); + + const foundToolCall = await rig.waitForAnyToolCall([ + 'replace', + 'write_file', + ]); + if (!foundToolCall) { + printDebugInfo(rig, result); + } + expect( + foundToolCall, + 'Expected to find a replace or write_file tool call', + ).toBeTruthy(); + + const toolLogs = rig.readToolLogs(); + const successfulEdit = toolLogs.some( + (log) => + (log.toolRequest.name === 'replace' || + log.toolRequest.name === 'write_file') && + log.toolRequest.success, + ); + if (!successfulEdit) { + console.error( + 'Expected a successful edit tool call, but none was found.', + ); + printDebugInfo(rig, result); + } + expect(successfulEdit, 'Expected a successful edit tool call').toBeTruthy(); + + const newFileContent = rig.readFile(fileName); + expect(newFileContent).toBe(expectedContent); + }); + + it('should fail safely when trying to edit a non-existent file', async () => { + const rig = new TestRig(); + await rig.setup( + 'should fail safely when trying to edit a non-existent file', + ); + const fileName = 'non_existent.txt'; + + const result = await rig.run(`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, + ); + }); }); diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 16cc5fe0..77105af2 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url'; import * as os from 'node:os'; import { - GEMINI_CONFIG_DIR, + QWEN_CONFIG_DIR, DEFAULT_CONTEXT_FILENAME, } from '../packages/core/src/tools/memoryTool.js'; @@ -33,7 +33,7 @@ let runDir = ''; // Make runDir accessible in teardown const memoryFilePath = join( os.homedir(), - GEMINI_CONFIG_DIR, + QWEN_CONFIG_DIR, DEFAULT_CONTEXT_FILENAME, ); let originalMemoryContent: string | null = null; diff --git a/integration-tests/ide-client.test.ts b/integration-tests/ide-client.test.ts deleted file mode 100644 index 2f6d3d06..00000000 --- a/integration-tests/ide-client.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @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 * as net from 'node:net'; -import * as child_process from 'node:child_process'; -import { IdeClient } from '../packages/core/src/ide/ide-client.js'; - -import { TestMcpServer } from './test-mcp-server.js'; - -describe.skip('IdeClient', () => { - it('reads port from file and connects', async () => { - const server = new TestMcpServer(); - const port = await server.start(); - const pid = process.pid; - const portFile = path.join(os.tmpdir(), `qwen-code-ide-server-${pid}.json`); - fs.writeFileSync(portFile, JSON.stringify({ port })); - process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = process.cwd(); - process.env['TERM_PROGRAM'] = 'vscode'; - - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - - expect(ideClient.getConnectionStatus()).toEqual({ - status: 'connected', - details: undefined, - }); - - fs.unlinkSync(portFile); - await server.stop(); - delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; - }); -}); - -const getFreePort = (): Promise => { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, () => { - const port = (server.address() as net.AddressInfo).port; - server.close(() => { - resolve(port); - }); - }); - }); -}; - -describe('IdeClient fallback connection logic', () => { - let server: TestMcpServer; - let envPort: number; - let pid: number; - let portFile: string; - - beforeEach(async () => { - pid = process.pid; - portFile = path.join(os.tmpdir(), `qwen-code-ide-server-${pid}.json`); - server = new TestMcpServer(); - envPort = await server.start(); - process.env['QWEN_CODE_IDE_SERVER_PORT'] = String(envPort); - process.env['TERM_PROGRAM'] = 'vscode'; - process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = process.cwd(); - // Reset instance - (IdeClient as unknown as { instance: IdeClient | undefined }).instance = - undefined; - }); - - afterEach(async () => { - await server.stop(); - delete process.env['QWEN_CODE_IDE_SERVER_PORT']; - delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; - if (fs.existsSync(portFile)) { - fs.unlinkSync(portFile); - } - }); - - it('connects using env var when port file does not exist', async () => { - // Ensure port file doesn't exist - if (fs.existsSync(portFile)) { - fs.unlinkSync(portFile); - } - - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - - expect(ideClient.getConnectionStatus()).toEqual({ - status: 'connected', - details: undefined, - }); - }); - - it('falls back to env var when connection with port from file fails', async () => { - const filePort = await getFreePort(); - // Write port file with a port that is not listening - fs.writeFileSync(portFile, JSON.stringify({ port: filePort })); - - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - - expect(ideClient.getConnectionStatus()).toEqual({ - status: 'connected', - details: undefined, - }); - }); -}); - -describe.skip('getIdeProcessId', () => { - let child: child_process.ChildProcess; - - afterEach(() => { - if (child) { - child.kill(); - } - }); - - it('should return the pid of the parent process', async () => { - // We need to spawn a child process that will run the test - // so that we can check that getIdeProcessId returns the pid of the parent - const parentPid = process.pid; - const output = await new Promise((resolve, reject) => { - child = child_process.spawn( - 'node', - [ - '-e', - ` - const { getIdeProcessId } = require('../packages/core/src/ide/process-utils.js'); - getIdeProcessId().then(pid => console.log(pid)); - `, - ], - { - stdio: ['pipe', 'pipe', 'pipe'], - }, - ); - - let out = ''; - child.stdout?.on('data', (data) => { - out += data.toString(); - }); - - child.on('close', (code) => { - if (code === 0) { - resolve(out.trim()); - } else { - reject(new Error(`Child process exited with code ${code}`)); - } - }); - }); - - expect(parseInt(output, 10)).toBe(parentPid); - }, 10000); -}); - -describe('IdeClient with proxy', () => { - let mcpServer: TestMcpServer; - let proxyServer: net.Server; - let mcpServerPort: number; - let proxyServerPort: number; - - beforeEach(async () => { - mcpServer = new TestMcpServer(); - mcpServerPort = await mcpServer.start(); - - proxyServer = net.createServer().listen(); - proxyServerPort = (proxyServer.address() as net.AddressInfo).port; - - vi.stubEnv('QWEN_CODE_IDE_SERVER_PORT', String(mcpServerPort)); - vi.stubEnv('TERM_PROGRAM', 'vscode'); - vi.stubEnv('QWEN_CODE_IDE_WORKSPACE_PATH', process.cwd()); - - // Reset instance - (IdeClient as unknown as { instance: IdeClient | undefined }).instance = - undefined; - }); - - afterEach(async () => { - (await IdeClient.getInstance()).disconnect(); - await mcpServer.stop(); - proxyServer.close(); - vi.unstubAllEnvs(); - }); - - it('should connect to IDE server when HTTP_PROXY, HTTPS_PROXY and NO_PROXY are set', async () => { - vi.stubEnv('HTTP_PROXY', `http://localhost:${proxyServerPort}`); - vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`); - vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1'); - - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - - expect(ideClient.getConnectionStatus()).toEqual({ - status: 'connected', - details: undefined, - }); - }); -}); diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts new file mode 100644 index 00000000..7a65a651 --- /dev/null +++ b/integration-tests/json-output.test.ts @@ -0,0 +1,89 @@ +/** + * @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('JSON output', () => { + let rig: TestRig; + + beforeEach(async () => { + rig = new TestRig(); + await rig.setup('json-output-test'); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should return a valid JSON with response and stats', async () => { + const result = await rig.run( + '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 JSON error for enforced auth mismatch before running', async () => { + process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; + await rig.setup('json-output-auth-mismatch', { + settings: { + security: { auth: { enforcedType: 'gemini-api-key' } }, + }, + }); + + let thrown: Error | undefined; + try { + await rig.run('Hello', '--output-format', 'json'); + expect.fail('Expected process to exit with error'); + } catch (e) { + thrown = e as Error; + } finally { + delete process.env['GOOGLE_GENAI_USE_GCA']; + } + + expect(thrown).toBeDefined(); + const message = (thrown as Error).message; + + // Use a regex to find the first complete JSON object in the string + const jsonMatch = message.match(/{[\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(1); + expect(payload.error.message).toContain( + 'configured auth type is gemini-api-key', + ); + expect(payload.error.message).toContain( + 'current auth type is oauth-personal', + ); + }); +}); diff --git a/integration-tests/list_directory.test.ts b/integration-tests/list_directory.test.ts index 5e841b17..6d3cc37a 100644 --- a/integration-tests/list_directory.test.ts +++ b/integration-tests/list_directory.test.ts @@ -29,7 +29,7 @@ describe('list_directory', () => { 50, // check every 50ms ); - const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`; + const prompt = `Can you list the files in the current directory.`; const result = await rig.run(prompt); diff --git a/integration-tests/mcp_server_cyclic_schema.test.ts b/integration-tests/mcp_server_cyclic_schema.test.ts index e85d2877..367e5cd5 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.ts +++ b/integration-tests/mcp_server_cyclic_schema.test.ts @@ -5,14 +5,26 @@ */ /** - * This test verifies we can match maximum schema depth errors from Gemini - * and then detect and warn about the potential tools that caused the error. + * 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 { describe, it, beforeAll, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; -import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } 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 @@ -180,15 +192,16 @@ describe('mcp server with cyclic tool schema is detected', () => { } }); - it('should error and suggest disabling the cyclic tool', async () => { - // Just run any command to trigger the schema depth error. - // If this test starts failing, check `isSchemaDepthError` from - // geminiChat.ts to see if it needs to be updated. - // Or, possibly it could mean that gemini has fixed the issue. - const output = await rig.run('hello'); + it('mcp tool with cyclic schema should be accessible', async () => { + const mcp_list_output = await rig.runCommand(['mcp', 'list']); - expect(output).toMatch( - /Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/, - ); + // Verify the cyclic schema server is configured + expect(mcp_list_output).toContain('cyclic-schema-server'); + }); + + it('gemini api call should be successful with cyclic mcp tool schema', async () => { + // Run any command and verify that we get a non-error response from + // the Gemini API. + await rig.run('hello'); }); }); diff --git a/integration-tests/mixed-input-crash.test.ts b/integration-tests/mixed-input-crash.test.ts new file mode 100644 index 00000000..e2db6473 --- /dev/null +++ b/integration-tests/mixed-input-crash.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { TestRig } from './test-helper.js'; + +describe('mixed input crash prevention', () => { + it('should not crash when using mixed prompt inputs', async () => { + const rig = new TestRig(); + 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( + { stdin: stdinContent }, + '--prompt-interactive', + "say '2'.", + "say '3'.", + ); + 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 1'); + 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 () => { + const rig = new TestRig(); + rig.setup('should provide clear error message for mixed input'); + + try { + await rig.run( + { stdin: 'test input' }, + '--prompt-interactive', + 'test prompt', + ); + 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', + ); + } + }); +}); diff --git a/integration-tests/read_many_files.test.ts b/integration-tests/read_many_files.test.ts index 15f8fcbe..39673243 100644 --- a/integration-tests/read_many_files.test.ts +++ b/integration-tests/read_many_files.test.ts @@ -14,7 +14,7 @@ describe('read_many_files', () => { rig.createFile('file1.txt', 'file 1 content'); rig.createFile('file2.txt', 'file 2 content'); - const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`; + 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(prompt); @@ -41,11 +41,7 @@ describe('read_many_files', () => { 'Expected to find either read_many_files or multiple read_file tool calls', ).toBeTruthy(); - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput( - result, - ['file 1 content', 'file 2 content'], - 'Read many files test', - ); + // Validate model output - will throw if no output + validateModelOutput(result, null, 'Read many files test'); }); }); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index a1aa08ae..cba8cb72 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -67,4 +67,64 @@ describe('run_shell_command', () => { // Validate model output - will throw if no output, warn if missing expected content validateModelOutput(result, 'test-stdin', 'Shell command stdin test'); }); + + it('should propagate environment variables to the child process', async () => { + const rig = new TestRig(); + await rig.setup('should propagate environment variables'); + + 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(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(); + validateModelOutput(result, varValue, 'Env var propagation test'); + expect(result).toContain(varValue); + } finally { + delete process.env[varName]; + } + }); + + it('should run a platform-specific file listing command', async () => { + const rig = new TestRig(); + 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(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(); + + validateModelOutput(result, fileName, 'Platform-specific listing test'); + expect(result).toContain(fileName); + }); }); diff --git a/integration-tests/save_memory.test.ts b/integration-tests/save_memory.test.ts index 15b062e9..40ede683 100644 --- a/integration-tests/save_memory.test.ts +++ b/integration-tests/save_memory.test.ts @@ -8,7 +8,9 @@ import { describe, it, expect } from 'vitest'; import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; describe('save_memory', () => { - it('should be able to save to memory', async () => { + // Skipped due to flaky model behavior - the model sometimes answers the question + // directly without calling the save_memory tool, even when prompted to "remember" + it.skip('should be able to save to memory', async () => { const rig = new TestRig(); await rig.setup('should be able to save to memory'); diff --git a/integration-tests/shell-service.test.ts b/integration-tests/shell-service.test.ts deleted file mode 100644 index 90984f20..00000000 --- a/integration-tests/shell-service.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { ShellExecutionService } from '../packages/core/src/services/shellExecutionService.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { vi } from 'vitest'; - -describe('ShellExecutionService programmatic integration tests', () => { - let testDir: string; - - beforeAll(async () => { - // Create a dedicated directory for this test suite to avoid conflicts. - testDir = path.join( - process.env['INTEGRATION_TEST_FILE_DIR']!, - 'shell-service-tests', - ); - await fs.mkdir(testDir, { recursive: true }); - }); - - it('should execute a simple cross-platform command (echo)', async () => { - const command = 'echo "hello from the service"'; - const onOutputEvent = vi.fn(); - const abortController = new AbortController(); - - const handle = await ShellExecutionService.execute( - command, - testDir, - onOutputEvent, - abortController.signal, - false, - ); - - const result = await handle.result; - - expect(result.error).toBeNull(); - expect(result.exitCode).toBe(0); - // Output can vary slightly between shells (e.g., quotes), so check for inclusion. - expect(result.output).toContain('hello from the service'); - }); - - it.runIf(process.platform === 'win32')( - 'should execute "dir" on Windows', - async () => { - const testFile = 'test-file-windows.txt'; - await fs.writeFile(path.join(testDir, testFile), 'windows test'); - - const command = 'dir'; - const onOutputEvent = vi.fn(); - const abortController = new AbortController(); - - const handle = await ShellExecutionService.execute( - command, - testDir, - onOutputEvent, - abortController.signal, - false, - ); - - const result = await handle.result; - - expect(result.error).toBeNull(); - expect(result.exitCode).toBe(0); - expect(result.output).toContain(testFile); - }, - ); - - it.skipIf(process.platform === 'win32')( - 'should execute "ls -l" on Unix', - async () => { - const testFile = 'test-file-unix.txt'; - await fs.writeFile(path.join(testDir, testFile), 'unix test'); - - const command = 'ls -l'; - const onOutputEvent = vi.fn(); - const abortController = new AbortController(); - - const handle = await ShellExecutionService.execute( - command, - testDir, - onOutputEvent, - abortController.signal, - false, - ); - - const result = await handle.result; - - expect(result.error).toBeNull(); - expect(result.exitCode).toBe(0); - expect(result.output).toContain(testFile); - }, - ); - - it('should abort a running process', async () => { - // A command that runs for a bit. 'sleep' on unix, 'timeout' on windows. - const command = process.platform === 'win32' ? 'timeout /t 20' : 'sleep 20'; - const onOutputEvent = vi.fn(); - const abortController = new AbortController(); - - const handle = await ShellExecutionService.execute( - command, - testDir, - onOutputEvent, - abortController.signal, - false, - ); - - // Abort shortly after starting - setTimeout(() => abortController.abort(), 50); - - const result = await handle.result; - - // For debugging the flaky test. - console.log('Abort test result:', result); - - expect(result.aborted).toBe(true); - // A clean exit is exitCode 0 and no signal. If the process was truly - // aborted, it should not have exited cleanly. - const exitedCleanly = result.exitCode === 0 && result.signal === null; - expect(exitedCleanly, 'Process should not have exited cleanly').toBe(false); - }); - - it('should propagate environment variables to the child process', async () => { - const varName = 'QWEN_CODE_TEST_VAR'; - const varValue = `test-value`; - process.env[varName] = varValue; - - try { - const command = - process.platform === 'win32' ? `echo %${varName}%` : `echo $${varName}`; - const onOutputEvent = vi.fn(); - const abortController = new AbortController(); - - const handle = await ShellExecutionService.execute( - command, - testDir, - onOutputEvent, - abortController.signal, - false, - ); - - const result = await handle.result; - - expect(result.error).toBeNull(); - expect(result.exitCode).toBe(0); - expect(result.output).toContain(varValue); - } finally { - // Clean up the env var to prevent side-effects on other tests. - delete process.env[varName]; - } - }); -}); diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index c7af1d43..d8b6268d 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -189,6 +189,25 @@ describe('simple-mcp-server', () => { 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 rig.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.'); + } }); it('should add two numbers', async () => { diff --git a/integration-tests/telemetry.test.ts b/integration-tests/telemetry.test.ts new file mode 100644 index 00000000..111f24c8 --- /dev/null +++ b/integration-tests/telemetry.test.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { TestRig } from './test-helper.js'; + +describe('telemetry', () => { + it('should emit a metric and a log event', async () => { + const rig = new TestRig(); + rig.setup('should emit a metric and a log event'); + + // Run a simple command that should trigger telemetry + await rig.run('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(); + }); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 6e9a35dd..330904d3 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -5,13 +5,15 @@ */ import { execSync, spawn } from 'node:child_process'; -import { parse } from 'shell-quote'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { env } from 'node:process'; +import { DEFAULT_QWEN_MODEL } from '../packages/core/src/config/models.js'; import fs from 'node:fs'; import { EOL } from 'node:os'; +import * as pty from '@lydell/node-pty'; +import stripAnsi from 'strip-ansi'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -112,11 +114,38 @@ export function validateModelOutput( return true; } +// Simulates typing a string one character at a time to avoid paste detection. +export async function type(ptyProcess: pty.IPty, text: string) { + const delay = 5; + for (const char of text) { + ptyProcess.write(char); + await new Promise((resolve) => setTimeout(resolve, delay)); + } +} + +interface ParsedLog { + attributes?: { + 'event.name'?: string; + function_name?: string; + function_args?: string; + success?: boolean; + duration_ms?: number; + }; + scopeMetrics?: { + metrics: { + descriptor: { + name: string; + }; + }[]; + }[]; +} + export class TestRig { bundlePath: string; testDir: string | null; testName?: string; _lastRunStdout?: string; + _interactiveOutput = ''; constructor() { this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); @@ -140,8 +169,8 @@ export class TestRig { mkdirSync(this.testDir, { recursive: true }); // Create a settings file to point the CLI to the local collector - const geminiDir = join(this.testDir, '.qwen'); - mkdirSync(geminiDir, { recursive: true }); + const qwenDir = join(this.testDir, '.qwen'); + mkdirSync(qwenDir, { recursive: true }); // In sandbox mode, use an absolute path for telemetry inside the container // The container mounts the test directory at the same path as the host const telemetryPath = join(this.testDir, 'telemetry.log'); // Always use test directory for telemetry @@ -153,12 +182,12 @@ export class TestRig { otlpEndpoint: '', outfile: telemetryPath, }, - sandbox: - env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false, + model: DEFAULT_QWEN_MODEL, + sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false, ...options.settings, // Allow tests to override/add settings }; writeFileSync( - join(geminiDir, 'settings.json'), + join(qwenDir, 'settings.json'), JSON.stringify(settings, null, 2), ); } @@ -178,13 +207,32 @@ export class TestRig { execSync('sync', { cwd: this.testDir! }); } + /** + * The command and args to use to invoke Qwen Code CLI. Allows us to switch + * between using the bundled gemini.js (the default) and using the installed + * 'qwen' (used to verify npm bundles). + */ + private _getCommandAndArgs(extraInitialArgs: string[] = []): { + command: string; + initialArgs: string[]; + } { + const isNpmReleaseTest = + process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; + const command = isNpmReleaseTest ? 'qwen' : 'node'; + const initialArgs = isNpmReleaseTest + ? extraInitialArgs + : [this.bundlePath, ...extraInitialArgs]; + return { command, initialArgs }; + } + run( promptOrOptions: | string | { prompt?: string; stdin?: string; stdinDoesNotEnd?: boolean }, ...args: string[] ): Promise { - let command = `node ${this.bundlePath} --yolo`; + const { command, initialArgs } = this._getCommandAndArgs(['--yolo']); + const commandArgs = [...initialArgs]; const execOptions: { cwd: string; encoding: 'utf-8'; @@ -195,27 +243,25 @@ export class TestRig { }; if (typeof promptOrOptions === 'string') { - command += ` --prompt ${JSON.stringify(promptOrOptions)}`; + commandArgs.push('--prompt', promptOrOptions); } else if ( typeof promptOrOptions === 'object' && promptOrOptions !== null ) { if (promptOrOptions.prompt) { - command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`; + commandArgs.push('--prompt', promptOrOptions.prompt); } if (promptOrOptions.stdin) { execOptions.input = promptOrOptions.stdin; } } - command += ` ${args.join(' ')}`; + commandArgs.push(...args); - const commandArgs = parse(command); - const node = commandArgs.shift() as string; - - const child = spawn(node, commandArgs as string[], { + const child = spawn(command, commandArgs, { cwd: this.testDir!, stdio: 'pipe', + env: process.env, }); let stdout = ''; @@ -291,8 +337,15 @@ export class TestRig { result = filteredLines.join('\n'); } - // If we have stderr output, include that also - if (stderr) { + + // Check if this is a JSON output test - if so, don't include stderr + // as it would corrupt the JSON + const isJsonOutput = + commandArgs.includes('--output-format') && + commandArgs.includes('json'); + + // If we have stderr output and it's not a JSON test, include that also + if (stderr && !isJsonOutput) { result += `\n\nStdErr:\n${stderr}`; } @@ -306,6 +359,58 @@ export class TestRig { return promise; } + runCommand( + args: string[], + options: { stdin?: string } = {}, + ): Promise { + const { command, initialArgs } = this._getCommandAndArgs(); + const commandArgs = [...initialArgs, ...args]; + + const child = spawn(command, commandArgs, { + cwd: this.testDir!, + stdio: 'pipe', + }); + + let stdout = ''; + let stderr = ''; + + if (options.stdin) { + child.stdin!.write(options.stdin); + child.stdin!.end(); + } + + child.stdout!.on('data', (data: Buffer) => { + stdout += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stdout.write(data); + } + }); + + child.stderr!.on('data', (data: Buffer) => { + stderr += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stderr.write(data); + } + }); + + const promise = new Promise((resolve, reject) => { + child.on('close', (code: number) => { + if (code === 0) { + this._lastRunStdout = stdout; + let result = stdout; + if (stderr) { + result += `\n\nStdErr:\n${stderr}`; + } + resolve(result); + } else { + reject(new Error(`Process exited with code ${code}:\n${stderr}`)); + } + }); + }); + + return promise; + } + readFile(fileName: string) { const filePath = join(this.testDir!, fileName); const content = readFileSync(filePath, 'utf-8'); @@ -363,37 +468,12 @@ export class TestRig { return this.poll( () => { - const logFilePath = join(this.testDir!, 'telemetry.log'); - - if (!logFilePath || !fs.existsSync(logFilePath)) { - return false; - } - - const content = readFileSync(logFilePath, 'utf-8'); - const jsonObjects = content - .split(/}\n{/) - .map((obj, index, array) => { - // Add back the braces we removed during split - if (index > 0) obj = '{' + obj; - if (index < array.length - 1) obj = obj + '}'; - return obj.trim(); - }) - .filter((obj) => obj); - - for (const jsonStr of jsonObjects) { - try { - const logData = JSON.parse(jsonStr); - if ( - logData.attributes && - logData.attributes['event.name'] === `gemini_cli.${eventName}` - ) { - return true; - } - } catch { - // ignore - } - } - return false; + const logs = this._readAndParseTelemetryLog(); + return logs.some( + (logData) => + logData.attributes && + logData.attributes['event.name'] === `qwen-code.${eventName}`, + ); }, timeout, 100, @@ -566,7 +646,7 @@ export class TestRig { } } else if ( obj.attributes && - obj.attributes['event.name'] === 'gemini_cli.tool_call' + obj.attributes['event.name'] === 'qwen-code.tool_call' ) { logs.push({ timestamp: obj.attributes['event.timestamp'], @@ -590,6 +670,45 @@ export class TestRig { return logs; } + private _readAndParseTelemetryLog(): ParsedLog[] { + // Telemetry is always written to the test directory + const logFilePath = join(this.testDir!, 'telemetry.log'); + + if (!logFilePath || !fs.existsSync(logFilePath)) { + return []; + } + + const content = readFileSync(logFilePath, 'utf-8'); + + // Split the content into individual JSON objects + // They are separated by "}\n{" + const jsonObjects = content + .split(/}\n{/) + .map((obj, index, array) => { + // Add back the braces we removed during split + if (index > 0) obj = '{' + obj; + if (index < array.length - 1) obj = obj + '}'; + return obj.trim(); + }) + .filter((obj) => obj); + + const logs: ParsedLog[] = []; + + for (const jsonStr of jsonObjects) { + try { + const logData = JSON.parse(jsonStr); + logs.push(logData); + } catch (e) { + // Skip objects that aren't valid JSON + if (env.VERBOSE === 'true') { + console.error('Failed to parse telemetry object:', e); + } + } + } + + return logs; + } + readToolLogs() { // For Podman, first check if telemetry file exists and has content // If not, fall back to parsing from stdout @@ -619,33 +738,7 @@ export class TestRig { } } - // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); - - if (!logFilePath) { - console.warn(`TELEMETRY_LOG_FILE environment variable not set`); - return []; - } - - // Check if file exists, if not return empty array (file might not be created yet) - if (!fs.existsSync(logFilePath)) { - return []; - } - - const content = readFileSync(logFilePath, 'utf-8'); - - // Split the content into individual JSON objects - // They are separated by "}\n{" - const jsonObjects = content - .split(/}\n{/) - .map((obj, index, array) => { - // Add back the braces we removed during split - if (index > 0) obj = '{' + obj; - if (index < array.length - 1) obj = obj + '}'; - return obj.trim(); - }) - .filter((obj) => obj); - + const parsedLogs = this._readAndParseTelemetryLog(); const logs: { toolRequest: { name: string; @@ -655,29 +748,21 @@ export class TestRig { }; }[] = []; - for (const jsonStr of jsonObjects) { - try { - const logData = JSON.parse(jsonStr); - // Look for tool call logs - if ( - logData.attributes && - logData.attributes['event.name'] === 'qwen-code.tool_call' - ) { - const toolName = logData.attributes.function_name; - logs.push({ - toolRequest: { - name: toolName, - args: logData.attributes.function_args, - success: logData.attributes.success, - duration_ms: logData.attributes.duration_ms, - }, - }); - } - } catch (e) { - // Skip objects that aren't valid JSON - if (env['VERBOSE'] === 'true') { - console.error('Failed to parse telemetry object:', e); - } + for (const logData of parsedLogs) { + // Look for tool call logs + if ( + logData.attributes && + logData.attributes['event.name'] === 'qwen-code.tool_call' + ) { + const toolName = logData.attributes.function_name; + logs.push({ + toolRequest: { + name: toolName, + args: logData.attributes.function_args, + success: logData.attributes.success, + duration_ms: logData.attributes.duration_ms, + }, + }); } } @@ -685,38 +770,79 @@ export class TestRig { } readLastApiRequest(): Record | null { - // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); + const logs = this._readAndParseTelemetryLog(); + const apiRequests = logs.filter( + (logData) => + logData.attributes && + logData.attributes['event.name'] === 'qwen-code.api_request', + ); + return apiRequests.pop() || null; + } - if (!logFilePath || !fs.existsSync(logFilePath)) { - return null; - } - - const content = readFileSync(logFilePath, 'utf-8'); - const jsonObjects = content - .split(/}\n{/) - .map((obj, index, array) => { - if (index > 0) obj = '{' + obj; - if (index < array.length - 1) obj = obj + '}'; - return obj.trim(); - }) - .filter((obj) => obj); - - let lastApiRequest = null; - - for (const jsonStr of jsonObjects) { - try { - const logData = JSON.parse(jsonStr); - if ( - logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.api_request' - ) { - lastApiRequest = logData; + readMetric(metricName: string): Record | null { + const logs = this._readAndParseTelemetryLog(); + for (const logData of logs) { + if (logData.scopeMetrics) { + for (const scopeMetric of logData.scopeMetrics) { + for (const metric of scopeMetric.metrics) { + if (metric.descriptor.name === `qwen-code.${metricName}`) { + return metric; + } + } } - } catch { - // ignore } } - return lastApiRequest; + return null; + } + + async waitForText(text: string, timeout?: number): Promise { + if (!timeout) { + timeout = this.getDefaultTimeout(); + } + return this.poll( + () => + stripAnsi(this._interactiveOutput) + .toLowerCase() + .includes(text.toLowerCase()), + timeout, + 200, + ); + } + + runInteractive(...args: string[]): { + ptyProcess: pty.IPty; + promise: Promise<{ exitCode: number; signal?: number; output: string }>; + } { + const { command, initialArgs } = this._getCommandAndArgs(['--yolo']); + const commandArgs = [...initialArgs, ...args]; + + this._interactiveOutput = ''; // Reset output for the new run + + const ptyProcess = pty.spawn(command, commandArgs, { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: this.testDir!, + env: process.env as { [key: string]: string }, + }); + + ptyProcess.onData((data) => { + this._interactiveOutput += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stdout.write(data); + } + }); + + const promise = new Promise<{ + exitCode: number; + signal?: number; + output: string; + }>((resolve) => { + ptyProcess.onExit(({ exitCode, signal }) => { + resolve({ exitCode, signal, output: this._interactiveOutput }); + }); + }); + + return { ptyProcess, promise }; } } diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/utf-bom-encoding.test.ts new file mode 100644 index 00000000..4429b700 --- /dev/null +++ b/integration-tests/utf-bom-encoding.test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { writeFileSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { TestRig } from './test-helper.js'; + +// Windows skip (Option A: avoid infra scope) +const d = process.platform === 'win32' ? describe.skip : describe; + +// 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]); +}; + +let rig: TestRig; +let dir: string; + +d('BOM end-to-end integration', () => { + beforeAll(async () => { + rig = new TestRig(); + await rig.setup('bom-integration'); + dir = rig.testDir!; + }); + + afterAll(async () => { + await rig.cleanup(); + }); + + async function runAndAssert( + filename: string, + content: Buffer, + expectedText: string | null, + ) { + writeFileSync(join(dir, filename), content); + const prompt = `read the file ${filename} and output its exact contents`; + const output = await rig.run(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(dir, filename), imageContent); + const prompt = `What is shown in the image ${filename}?`; + const output = await rig.run(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(); + }); +}); diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index 888343ca..c8b79ad6 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -17,6 +17,12 @@ export default defineConfig({ include: ['**/*.test.ts'], exclude: ['**/terminal-bench/*.test.ts', '**/node_modules/**'], retry: 2, - fileParallelism: false, + fileParallelism: true, + poolOptions: { + threads: { + minThreads: 2, + maxThreads: 4, + }, + }, }, }); diff --git a/package-lock.json b/package-lock.json index 62353a65..006aadf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,8 @@ "packages/*" ], "dependencies": { - "@lvce-editor/ripgrep": "^1.6.0", - "simple-git": "^3.28.0", - "strip-ansi": "^7.1.0" + "@testing-library/dom": "^10.4.1", + "simple-git": "^3.28.0" }, "bin": { "qwen": "bundle/gemini.js" @@ -28,7 +27,6 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", - "concurrently": "^9.2.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", "eslint": "^9.24.0", @@ -39,14 +37,19 @@ "eslint-plugin-react-hooks": "^5.2.0", "glob": "^10.4.5", "globals": "^16.0.0", + "google-artifactregistry-auth": "^3.4.0", + "husky": "^9.1.7", "json": "^11.0.0", - "lodash": "^4.17.21", - "memfs": "^4.17.2", + "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": "^4.28.5", + "semver": "^7.7.2", + "strip-ansi": "^7.1.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", @@ -133,6 +136,202 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.0.tgz", + "integrity": "sha512-88Djs5vBvGbHQHf5ZZcaoNHo6Y8BKZkt3cw2iuJIQzLEgH4Ox6Tm4hjFhbqOxyYsgIG/eJbFEHpxRIfEEWv5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.0.tgz", + "integrity": "sha512-O4aP3CLFNodg8eTHXECaH3B3CjicfzkxVtnrfLkOq0XNP7TIECGfHpK/C6vADZkWP75wzmdBnsIA8ksuJMk18g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-rest-pipeline": "^1.20.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.6.1", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.0.tgz", + "integrity": "sha512-OKHmb3/Kpm06HypvB3g6Q3zJuvyXcpxDpCS1PnU8OV6AJgSFaee/covXBcPbWc6XDDxtEPlbi3EMQ6nUiPaQtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.8.0", + "@azure/core-tracing": "^1.0.1", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.0.tgz", + "integrity": "sha512-+XvmZLLWPe67WXNZo9Oc9CrPj/Tm8QnHR92fFAFdnbzwNdCH1h+7UdpaQgRSBsMY+oW1kHXNUZQLdZ1gHX3ROw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.0.tgz", + "integrity": "sha512-o0psW8QWQ58fq3i24Q1K2XfS/jYTxr7O1HRcyUE9bV9NttLU+kYOH82Ixj8DGlMTOWgxm1Sss2QAfKK5UkSPxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.11.1.tgz", + "integrity": "sha512-0ZdsLRaOyLxtCYgyuqyWqGU5XQ9gGnjxgfoNTt1pvELGkkUFrMATABZFIq8gusM7N1qbqpVtwLOhk0d/3kacLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.21.1.tgz", + "integrity": "sha512-qGtzX3HJfJsOVeDcVrFZAYZoxLRjrW2lXzXqijgiBA5EtM9ud7F/EYgKKQ9TJU/WtE46szuZtQZx5vD4pEiknA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.12.0.tgz", + "integrity": "sha512-4ucXbjVw8KJ5QBgnGJUeA07c8iznwlk5ioHIhI4ASXcXgcf2yRFhWzYOyWg/cI49LC9ekpFJeQtO3zjDTbl6TQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.7.3.tgz", + "integrity": "sha512-MoJxkKM/YpChfq4g2o36tElyzNUMG8mfD6u8NbuaPAsqfGpaw249khAcJYNoIOigUzRw45OjXCOrexE6ImdUxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.12.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -192,7 +391,6 @@ "version": "7.27.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -967,13 +1165,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -981,9 +1179,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -998,9 +1196,9 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.13.0.tgz", - "integrity": "sha512-BxilXzE8cJ0zt5/lXk6KwuBcIT9P2Lbi2WXhwWMbxf1RNeC68/8DmYQqMrzQP333CieRMdbDXs0eNCphLoScWg==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.16.0.tgz", + "integrity": "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^9.14.2", @@ -1010,7 +1208,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" + "@modelcontextprotocol/sdk": "^1.11.4" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -1187,51 +1385,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@inquirer/core/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inquirer/core/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/core/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@inquirer/core/node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -1245,21 +1398,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@inquirer/figures": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", @@ -1288,6 +1426,29 @@ } } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1305,6 +1466,18 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -1328,6 +1501,28 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@joshua.litt/get-ripgrep": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz", + "integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==", + "dependencies": { + "@lvce-editor/verror": "^1.6.0", + "execa": "^9.5.2", + "extract-zip": "^2.0.1", + "fs-extra": "^11.3.0", + "got": "^14.4.5", + "path-exists": "^5.0.0", + "xdg-basedir": "^5.1.0" + } + }, + "node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1408,17 +1603,75 @@ "tslib": "2" } }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", + "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", - "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz", + "integrity": "sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.1", + "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" }, "engines": { "node": ">=10.0" @@ -1432,11 +1685,15 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", - "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, "engines": { "node": ">=10.0" }, @@ -1463,32 +1720,6 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, - "node_modules/@lvce-editor/ripgrep": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-1.6.0.tgz", - "integrity": "sha512-880taWBVULNXmcPHXdxnFUI0FvLErBOjY9OigMXEsLZ2Q1rjcm6LixOkaccKWC8qFMpzm/ldkO7WOMK+ZRfk5Q==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@lvce-editor/verror": "^1.6.0", - "execa": "^9.5.2", - "extract-zip": "^2.0.1", - "fs-extra": "^11.3.0", - "got": "^14.4.5", - "path-exists": "^5.0.0", - "tempy": "^3.1.0", - "xdg-basedir": "^5.1.0" - } - }, - "node_modules/@lvce-editor/ripgrep/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/@lvce-editor/verror": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz", @@ -2293,6 +2524,24 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/resource-detector-gcp": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.40.0.tgz", + "integrity": "sha512-uAsUV8K4R9OJ3cgPUGYDqQByxOMTz4StmzJyofIv7+W+c1dTSEc1WVjWpTS2PAmywik++JlSmd8O4rMRJZpO8Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, "node_modules/@opentelemetry/resources": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", @@ -2841,6 +3090,204 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -2897,13 +3344,192 @@ "node": ">=14.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", + "integrity": "sha512-9ByYNzWV8tpz6BFaRzeRzIov8dkbSZu9q7IWqEIfmRuLWb2qbI/5gTvKcoWT1HYs4XM7IZ8TKSXcuPvMb6eorA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.2.2.tgz", + "integrity": "sha512-oMVaMJ3exFvXhCj3AqmCbLaeYrTNLqaJnLJMIlmnRM3/kZdxvku4OYdaDzgtlI194cVxamOY5AbHBBVnY79kEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.2.2", + "@textlint/resolver": "15.2.2", + "@textlint/types": "15.2.2", + "chalk": "^4.1.2", + "debug": "^4.4.1", + "js-yaml": "^3.14.1", + "lodash": "^4.17.21", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.2.2.tgz", + "integrity": "sha512-2rmNcWrcqhuR84Iio1WRzlc4tEoOMHd6T7urjtKNNefpTt1owrTJ9WuOe60yD3FrTW0J/R0ux5wxUbP/eaeFOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.2.2.tgz", + "integrity": "sha512-4hGWjmHt0y+5NAkoYZ8FvEkj8Mez9TqfbTm3BPjoV32cIfEixl2poTOgapn1rfm73905GSO3P1jiWjmgvii13Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.2.2.tgz", + "integrity": "sha512-X2BHGAR3yXJsCAjwYEDBIk9qUDWcH4pW61ISfmtejau+tVqKtnbbvEZnMTb6mWgKU1BvTmftd5DmB1XVDUtY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.2.2" + } + }, + "node_modules/@types/archiver": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -3029,16 +3655,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", - "license": "MIT", - "dependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "*" - } - }, "node_modules/@types/gradient-string": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", @@ -3090,23 +3706,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -3132,6 +3731,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, "license": "MIT" }, "node_modules/@types/mock-fs": { @@ -3207,6 +3807,23 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", @@ -3251,6 +3868,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@types/tinycolor2": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", @@ -3539,19 +4177,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.35.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", @@ -3594,6 +4219,21 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz", + "integrity": "sha512-sOx1PKSuFwnIl7z4RN0Ls7N9AQawmR9r66eI5rFCzLDIs8HTIYrIpH9QjYWoX0lkgGrkLxXhi4QnK7MizPRrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -3766,18 +4406,351 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vscode/vsce": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz", + "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.1", + "@secretlint/secretlint-formatter-sarif": "^10.1.1", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.1", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.1", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.1", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.6.tgz", + "integrity": "sha512-j9Ashk+uOWCDHYDxgGsqzKq5FXW9b9MW7QqOIYZ8IYpneJclWTBeHZz2DJCSKQgo+JAqNcaRRE1hzIx0dswqAw==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.5", + "@vscode/vsce-sign-alpine-x64": "2.0.5", + "@vscode/vsce-sign-darwin-arm64": "2.0.5", + "@vscode/vsce-sign-darwin-x64": "2.0.5", + "@vscode/vsce-sign-linux-arm": "2.0.5", + "@vscode/vsce-sign-linux-arm64": "2.0.5", + "@vscode/vsce-sign-linux-x64": "2.0.5", + "@vscode/vsce-sign-win32-arm64": "2.0.5", + "@vscode/vsce-sign-win32-x64": "2.0.5" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.5.tgz", + "integrity": "sha512-XVmnF40APwRPXSLYA28Ye+qWxB25KhSVpF2eZVtVOs6g7fkpOxsVnpRU1Bz2xG4ySI79IRuapDJoAQFkoOgfdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.5.tgz", + "integrity": "sha512-JuxY3xcquRsOezKq6PEHwCgd1rh1GnhyH6urVEWUzWn1c1PC4EOoyffMD+zLZtFuZF5qR1I0+cqDRNKyPvpK7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.5.tgz", + "integrity": "sha512-z2Q62bk0ptADFz8a0vtPvnm6vxpyP3hIEYMU+i1AWz263Pj8Mc38cm/4sjzxu+LIsAfhe9HzvYNS49lV+KsatQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.5.tgz", + "integrity": "sha512-ma9JDC7FJ16SuPXlLKkvOD2qLsmW/cKfqK4zzM2iJE1PbckF3BlR08lYqHV89gmuoTpYB55+z8Y5Fz4wEJBVDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.5.tgz", + "integrity": "sha512-cdCwtLGmvC1QVrkIsyzv01+o9eR+wodMJUZ9Ak3owhcGxPRB53/WvrDHAFYA6i8Oy232nuen1YqWeEohqBuSzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.5.tgz", + "integrity": "sha512-Hr1o0veBymg9SmkCqYnfaiUnes5YK6k/lKFA5MhNmiEN5fNqxyPUCdRZMFs3Ajtx2OFW4q3KuYVRwGA7jdLo7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.5.tgz", + "integrity": "sha512-XLT0gfGMcxk6CMRLDkgqEPTyG8Oa0OFe1tPv2RVbphSOjFWJwZgK3TYWx39i/7gqpDHlax0AP6cgMygNJrA6zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.5.tgz", + "integrity": "sha512-hco8eaoTcvtmuPhavyCZhrk5QIcLiyAUhEso87ApAWDllG7djIrWiOCtqn48k4pHz+L8oCQlE0nwNHfcYcxOPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.5.tgz", + "integrity": "sha512-1ixKFGM2FwM+6kQS2ojfY3aAelICxjiCzeg4nTHpkeU1Tfs4RC+lVLrgq5NwcBC7ZLr6UfY3Ct3D6suPeOf7BQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vscode/vsce/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vscode/vsce/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@vscode/vsce/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@vscode/vsce/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", "integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==", "license": "MIT" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -3786,22 +4759,11 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/accepts/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -3913,15 +4875,6 @@ "string-width": "^4.1.0" } }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-align/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3970,9 +4923,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3996,6 +4949,150 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4003,6 +5100,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4050,6 +5156,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "license": "MIT" + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -4192,6 +5304,23 @@ "js-tokens": "^9.0.1" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4202,6 +5331,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -4239,12 +5375,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4274,12 +5443,40 @@ "node": "*" } }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -4304,7 +5501,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -4314,7 +5510,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -4326,15 +5521,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -4345,6 +5538,20 @@ "node": ">= 0.8" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/boxen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", @@ -4403,6 +5610,32 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -4610,6 +5843,92 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -4758,15 +6077,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4816,6 +6126,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -4846,12 +6166,117 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", "license": "MIT" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/comment-json": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4859,32 +6284,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -4963,8 +6362,13 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" }, "node_modules/cors": { "version": "2.8.5", @@ -4979,6 +6383,75 @@ "node": ">= 0.10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -5012,31 +6485,34 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "license": "MIT", + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "license": "(MIT OR CC0-1.0)", + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">= 6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/fb55" } }, "node_modules/cssstyle": { @@ -5299,6 +6775,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5322,12 +6808,22 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -5367,9 +6863,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -5494,6 +6988,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5515,6 +7026,20 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -5549,9 +7074,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6002,6 +7527,16 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-license-header": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz", @@ -6076,6 +7611,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -6124,6 +7669,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -6189,6 +7747,43 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6248,6 +7843,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -6340,13 +7946,6 @@ "ms": "2.0.0" } }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true - }, "node_modules/express/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -6404,6 +8003,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6650,6 +8256,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", @@ -6659,6 +8282,19 @@ "node": ">= 18" } }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6679,11 +8315,18 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "11.3.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", @@ -6915,6 +8558,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -6948,6 +8599,23 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regex.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz", + "integrity": "sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -7017,6 +8685,90 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globby/node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-artifactregistry-auth": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/google-artifactregistry-auth/-/google-artifactregistry-auth-3.4.0.tgz", + "integrity": "sha512-Z2EmP7gbKtTmK5k846tfF7dQqeU2vREIcfCI79FKRTAdkbUZ/BFGJwwTvC2ss0vYSySvK7h6I1JsqBFqIXABBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.0", + "yargs": "^17.1.1" + }, + "bin": { + "artifactregistry-auth": "src/main.js" + } + }, "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -7056,9 +8808,9 @@ } }, "node_modules/got": { - "version": "14.4.7", - "resolved": "https://registry.npmjs.org/got/-/got-14.4.7.tgz", - "integrity": "sha512-DI8zV1231tqiGzOiOzQWDhsBmncFW7oQDH6Zgy6pDPrqJuVZMtoSgPLLsBZQj8Jg4JFfwoOsDA8NGtLQLnIx2g==", + "version": "14.4.8", + "resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz", + "integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==", "license": "MIT", "dependencies": { "@sindresorhus/is": "^7.0.1", @@ -7163,6 +8915,15 @@ "node": ">=8" } }, + "node_modules/has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -7395,6 +9156,22 @@ "node": ">=18.18.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -7417,6 +9194,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7592,6 +9390,74 @@ "ink": ">=4" } }, + "node_modules/ink-link/node_modules/ansi-escapes": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", + "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink-link/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink-link/node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ink-link/node_modules/terminal-link": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-3.0.0.tgz", + "integrity": "sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^5.0.0", + "supports-hyperlinks": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink-link/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ink-spinner": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", @@ -7721,23 +9587,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8407,6 +10256,24 @@ "node": ">=8" } }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8534,6 +10401,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8560,6 +10437,13 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -8582,14 +10466,60 @@ } }, "node_modules/jsonrepair": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.0.tgz", - "integrity": "sha512-5YRzlAQ7tuzV1nAJu3LvDlrKtBFIALHN2+a+I1MGJCt3ldRDBF/bZuvIPzae8Epot6KBXd0awRZZcuoeAsZ/mw==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", + "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==", "license": "ISC", "bin": { "jsonrepair": "bin/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8627,6 +10557,19 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8663,6 +10606,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -8672,6 +10668,16 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8686,6 +10692,145 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lint-staged": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", + "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.0", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^9.0.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/listr2": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -8739,18 +10884,54 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8758,6 +10939,89 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -8826,9 +11090,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8871,17 +11133,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, - "engines": { - "node": ">=10" + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/marked": { @@ -8905,26 +11172,34 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/memfs": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", - "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.42.0.tgz", + "integrity": "sha512-RG+4HMGyIVp6UWDWbFmZ38yKrSzblPnfJu0PyPt0hw52KW4PPlPp+HdV4qZBG0hLDuYVnf8wfQT4NymKXnlQjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", "tslib": "^2.0.0" }, "engines": { @@ -8949,7 +11224,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -9018,6 +11292,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -9061,6 +11348,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -9174,6 +11481,19 @@ "license": "MIT", "optional": true }, + "node_modules/nano-spawn": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz", + "integrity": "sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9193,6 +11513,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9205,7 +11533,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9217,6 +11544,28 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9270,6 +11619,20 @@ "nan": "^2.17.0" } }, + "node_modules/node-sarif-builder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz", + "integrity": "sha512-kVIOdynrF2CRodHZeP/97Rh1syTUHBNiw17hUCIVhlhEsWlfJm19MuO56s4MdKbr22xWx6mzMnNAgXzVlIYM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -9284,16 +11647,14 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, "node_modules/normalize-url": { @@ -9308,6 +11669,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -9406,16 +11777,6 @@ "node": ">=0.8.0" } }, - "node_modules/npm-run-all/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/npm-run-all/node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -9494,19 +11855,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-all/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/npm-run-all/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -9520,6 +11868,88 @@ "which": "bin/which" } }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-all2/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm-run-all2/node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/npm-run-all2/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -9560,6 +11990,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -9853,6 +12296,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -9877,18 +12333,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/package-json/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9943,6 +12387,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -9956,6 +12420,33 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -10144,6 +12635,16 @@ "node": ">=16.20.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10183,6 +12684,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10259,6 +12788,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10345,6 +12891,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pupa": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", @@ -10373,7 +12929,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.0.6" }, @@ -10467,6 +13022,32 @@ "rc": "cli.js" } }, + "node_modules/rc-config-loader": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz", + "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "js-yaml": "^4.1.0", + "json5": "^2.2.2", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc-config-loader/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -10558,6 +13139,33 @@ "react": "^19.1.0" } }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -10618,6 +13226,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10689,6 +13353,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10832,6 +13505,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -10940,16 +13620,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -11031,6 +13701,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -11050,6 +13727,28 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -11063,13 +13762,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -11077,7 +13778,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -11102,7 +13802,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -11111,15 +13810,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/send/node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -11129,7 +13826,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "peer": true, "bin": { "mime": "cli.js" }, @@ -11142,7 +13838,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -11152,7 +13847,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -11342,6 +14036,55 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-git": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", @@ -11357,6 +14100,19 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slice-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", @@ -11442,6 +14198,13 @@ "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "license": "CC0-1.0" }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -11500,6 +14263,18 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -11507,6 +14282,26 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -11539,15 +14334,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -11684,9 +14470,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11711,15 +14497,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11767,44 +14544,66 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, "node_modules/stubborn-fs": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=4" + } + }, + "node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, "node_modules/supports-hyperlinks/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -11832,83 +14631,168 @@ "dev": true, "license": "MIT" }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "license": "MIT", + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=14.16" + "node": ">=10.0.0" } }, - "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, "license": "MIT", "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/tempy/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" } }, "node_modules/terminal-link": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-3.0.0.tgz", - "integrity": "sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^5.0.0", - "supports-hyperlinks": "^2.2.0" + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link/node_modules/ansi-escapes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", - "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11955,15 +14839,52 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "dev": true, - "license": "Unlicense", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/thingies": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10.18" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, "peerDependencies": { "tslib": "^2" } @@ -12099,6 +15020,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12164,16 +15095,6 @@ "tslib": "2" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -12227,6 +15148,30 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12257,7 +15202,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", - "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -12266,22 +15210,11 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/type-is/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12367,6 +15300,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -12404,6 +15349,13 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -12423,10 +15375,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "dev": true, + "license": "MIT" + }, "node_modules/undici": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", - "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", + "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -12450,21 +15409,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -12508,18 +15452,6 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, - "node_modules/update-notifier/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/update-notifier/node_modules/boxen": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", @@ -12572,18 +15504,6 @@ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, - "node_modules/update-notifier/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/update-notifier/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -12628,23 +15548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/update-notifier/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -12654,6 +15557,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -12665,6 +15575,13 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12707,6 +15624,19 @@ "node": ">= 0.8" } }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/vite": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", @@ -13132,17 +16062,17 @@ } }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -13166,15 +16096,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -13219,6 +16140,29 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13268,6 +16212,30 @@ "node": ">=18" } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -13284,6 +16252,28 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -13311,15 +16301,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -13362,6 +16343,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13406,6 +16397,63 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -13424,56 +16472,29 @@ "zod": "^3.24.1" } }, - "packages/a2a-server": { - "name": "@google/gemini-cli-a2a-server", - "version": "0.3.4", - "extraneous": true, - "dependencies": { - "@a2a-js/sdk": "^0.3.2", - "@google-cloud/storage": "^7.16.0", - "@google/gemini-cli-core": "file:../core", - "express": "^5.1.0", - "fs-extra": "^11.3.0", - "tar": "^7.4.3", - "uuid": "^11.1.0", - "winston": "^3.17.0" - }, - "devDependencies": { - "@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" - } - }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.0.14", "dependencies": { - "@google/genai": "1.9.0", + "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.15.1", "@qwen-code/qwen-code-core": "file:../core", "@types/update-notifier": "^6.0.8", + "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", + "comment-json": "^4.2.5", "diff": "^7.0.0", "dotenv": "^17.1.0", + "extract-zip": "^2.0.1", "fzf": "^0.5.2", - "glob": "^10.4.1", + "glob": "^10.4.5", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", - "lodash-es": "^4.17.21", "lowlight": "^3.3.0", - "mime-types": "^3.0.1", "open": "^10.1.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", @@ -13483,8 +16504,10 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.1", "undici": "^7.10.0", "update-notifier": "^7.3.1", + "wrap-ansi": "9.0.2", "yargs": "^17.7.2", "zod": "^3.23.8" }, @@ -13496,16 +16519,18 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@qwen-code/qwen-code-test-utils": "file:../test-utils", "@testing-library/react": "^16.3.0", + "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", - "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.24", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", + "@types/tar": "^6.1.13", "@types/yargs": "^17.0.32", + "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", "pretty-format": "^30.0.2", @@ -13517,64 +16542,10 @@ "node": ">=20" } }, - "packages/cli/node_modules/@google/genai": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", - "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^9.14.2", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "packages/cli/node_modules/@testing-library/dom": { - "version": "10.4.0", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "packages/cli/node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, "packages/cli/node_modules/@testing-library/react": { "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dev": true, "license": "MIT", "dependencies": { @@ -13599,54 +16570,10 @@ } } }, - "packages/cli/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "packages/cli/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "packages/cli/node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "packages/cli/node_modules/emoji-regex": { - "version": "10.4.0", - "license": "MIT" - }, - "packages/cli/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "packages/cli/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -13664,8 +16591,8 @@ "name": "@qwen-code/qwen-code-core", "version": "0.0.14", "dependencies": { - "@google/genai": "1.13.0", - "@lvce-editor/ripgrep": "^1.6.0", + "@google/genai": "1.16.0", + "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", @@ -13675,8 +16602,8 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resource-detector-gcp": "^0.40.0", "@opentelemetry/sdk-node": "^0.203.0", - "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", "@xterm/headless": "5.5.0", "ajv": "^8.17.1", @@ -13695,6 +16622,7 @@ "ignore": "^7.0.0", "jsonrepair": "^3.13.0", "marked": "^15.0.12", + "mime": "4.0.7", "mnemonist": "^0.40.3", "open": "^10.1.2", "openai": "5.11.0", @@ -13715,6 +16643,7 @@ "@types/minimatch": "^5.1.2", "@types/picomatch": "^4.0.1", "@types/ws": "^8.5.10", + "msw": "^2.3.4", "typescript": "^5.3.3", "vitest": "^3.1.1" }, @@ -13763,16 +16692,27 @@ }, "packages/core/node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" } }, - "packages/core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "packages/core/node_modules/mime": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", + "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } }, "packages/core/node_modules/picomatch": { "version": "4.0.3", @@ -13815,9 +16755,10 @@ "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", + "@vscode/vsce": "^3.6.0", "esbuild": "^0.25.3", "eslint": "^9.25.1", - "npm-run-all": "^4.1.5", + "npm-run-all2": "^8.0.2", "typescript": "^5.8.3", "vitest": "^3.2.4" }, @@ -13832,39 +16773,6 @@ "dev": true, "license": "MIT" }, - "packages/vscode-ide-companion/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/vscode-ide-companion/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/vscode-ide-companion/node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -13877,15 +16785,6 @@ "node": ">= 0.6" } }, - "packages/vscode-ide-companion/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -13945,45 +16844,6 @@ "node": ">= 0.8" } }, - "packages/vscode-ide-companion/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "packages/vscode-ide-companion/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "packages/vscode-ide-companion/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/vscode-ide-companion/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "packages/vscode-ide-companion/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -14021,21 +16881,6 @@ "node": ">= 18" } }, - "packages/vscode-ide-companion/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, "packages/vscode-ide-companion/node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 4b034fe6..06887a46 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.14" }, "scripts": { - "start": "node scripts/start.js", + "start": "cross-env node scripts/start.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "auth:npm": "npx google-artifactregistry-auth", "auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev", @@ -27,30 +27,40 @@ "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 --skip-npm-install-build", + "build:sandbox": "node scripts/build_sandbox.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "test": "npm run test --workspaces --if-present", - "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", + "test": "npm run test --workspaces --if-present --parallel", + "test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts", "test:scripts": "vitest run --config ./scripts/tests/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": "GEMINI_SANDBOX=false vitest run --root ./integration-tests", - "test:integration:sandbox:docker": "GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests", - "test:integration:sandbox:podman": "GEMINI_SANDBOX=podman vitest run --root ./integration-tests", + "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 && GEMINI_SANDBOX=docker vitest run --root ./integration-tests", + "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", "test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'", "test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'", "lint": "eslint . --ext .ts,.tsx && eslint integration-tests", "lint:fix": "eslint . --fix && eslint integration-tests --fix", "lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0", + "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 lint:ci && npm run build && npm run typecheck && npm run test:ci", - "prepare": "npm run bundle", + "prepare": "husky && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "release:version": "node scripts/version.js", "telemetry": "node scripts/telemetry.js", - "clean": "node scripts/clean.js" + "check:lockfile": "node scripts/check-lockfile.js", + "clean": "node scripts/clean.js", + "pre-commit": "node scripts/pre-commit.js" + }, + "overrides": { + "wrap-ansi": "9.0.2", + "ansi-regex": "6.2.2", + "cliui": { + "wrap-ansi": "7.0.0" + } }, "bin": { "qwen": "bundle/gemini.js" @@ -70,7 +80,6 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", - "concurrently": "^9.2.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", "eslint": "^9.24.0", @@ -81,23 +90,27 @@ "eslint-plugin-react-hooks": "^5.2.0", "glob": "^10.4.5", "globals": "^16.0.0", + "google-artifactregistry-auth": "^3.4.0", + "husky": "^9.1.7", "json": "^11.0.0", - "lodash": "^4.17.21", - "memfs": "^4.17.2", + "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": "^4.28.5", + "semver": "^7.7.2", + "strip-ansi": "^7.1.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", "yargs": "^17.7.2" }, "dependencies": { - "@lvce-editor/ripgrep": "^1.6.0", - "simple-git": "^3.28.0", - "strip-ansi": "^7.1.0" + "@testing-library/dom": "^10.4.1", + "simple-git": "^3.28.0" }, "optionalDependencies": { "@lydell/node-pty": "1.1.0", @@ -107,5 +120,14 @@ "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", "node-pty": "^1.0.0" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "prettier --write", + "eslint --fix --max-warnings 0" + ], + "*.{json,md}": [ + "prettier --write" + ] } } diff --git a/packages/cli/package.json b/packages/cli/package.json index a736e655..89e3ddba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,7 +18,7 @@ "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", "test": "vitest run", - "test:ci": "vitest run --coverage", + "test:ci": "vitest run", "typecheck": "tsc --noEmit" }, "files": [ @@ -28,35 +28,38 @@ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.14" }, "dependencies": { - "@google/genai": "1.9.0", + "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", "@qwen-code/qwen-code-core": "file:../core", "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", + "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", + "comment-json": "^4.2.5", "diff": "^7.0.0", "dotenv": "^17.1.0", "fzf": "^0.5.2", - "glob": "^10.4.1", + "glob": "^10.4.5", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", - "lodash-es": "^4.17.21", "lowlight": "^3.3.0", - "mime-types": "^3.0.1", "open": "^10.1.2", "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", - "simple-git": "^3.28.0", "shell-quote": "^1.8.3", + "simple-git": "^3.28.0", "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "tar": "^7.5.1", "undici": "^7.10.0", + "extract-zip": "^2.0.1", "update-notifier": "^7.3.1", + "wrap-ansi": "9.0.2", "yargs": "^17.7.2", "zod": "^3.23.8" }, @@ -64,16 +67,18 @@ "@babel/runtime": "^7.27.6", "@google/gemini-cli-test-utils": "file:../test-utils", "@testing-library/react": "^16.3.0", + "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", - "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.24", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", + "@types/tar": "^6.1.13", "@types/yargs": "^17.0.32", + "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", "pretty-format": "^30.0.2", diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index ac3763f5..12b49e89 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -11,6 +11,8 @@ 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'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -23,6 +25,8 @@ export const extensionsCommand: CommandModule = { .command(updateCommand) .command(disableCommand) .command(enableCommand) + .command(linkCommand) + .command(newCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 139e7da8..0a88ce08 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -11,12 +11,16 @@ import { getErrorMessage } from '../../utils/errors.js'; interface DisableArgs { name: string; - scope: SettingScope; + scope?: string; } -export async function handleDisable(args: DisableArgs) { +export function handleDisable(args: DisableArgs) { try { - disableExtension(args.name, args.scope); + if (args.scope?.toLowerCase() === 'workspace') { + disableExtension(args.name, SettingScope.Workspace); + } else { + disableExtension(args.name, SettingScope.User); + } console.log( `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, ); @@ -39,13 +43,28 @@ export const disableCommand: CommandModule = { describe: 'The scope to disable the extenison in.', type: 'string', default: SettingScope.User, - choices: [SettingScope.User, SettingScope.Workspace], }) - .check((_argv) => true), - handler: async (argv) => { - await handleDisable({ + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), + handler: (argv) => { + handleDisable({ name: argv['name'] as string, - scope: argv['scope'] as SettingScope, + scope: argv['scope'] as string, }); }, }; diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 948fd1c2..7a77112d 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -11,15 +11,16 @@ import { SettingScope } from '../../config/settings.js'; interface EnableArgs { name: string; - scope?: SettingScope; + scope?: string; } -export async function handleEnable(args: EnableArgs) { +export function handleEnable(args: EnableArgs) { try { - const scopes = args.scope - ? [args.scope] - : [SettingScope.User, SettingScope.Workspace]; - enableExtension(args.name, scopes); + if (args.scope?.toLowerCase() === 'workspace') { + enableExtension(args.name, SettingScope.Workspace); + } else { + enableExtension(args.name, SettingScope.User); + } if (args.scope) { console.log( `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, @@ -35,7 +36,7 @@ export async function handleEnable(args: EnableArgs) { } export const enableCommand: CommandModule = { - command: 'disable [--scope] ', + command: 'enable [--scope] ', describe: 'Enables an extension.', builder: (yargs) => yargs @@ -47,13 +48,28 @@ export const enableCommand: CommandModule = { describe: 'The scope to enable the extenison in. If not set, will be enabled in all scopes.', type: 'string', - choices: [SettingScope.User, SettingScope.Workspace], }) - .check((_argv) => true), - handler: async (argv) => { - await handleEnable({ + .check((argv) => { + if ( + argv.scope && + !Object.values(SettingScope) + .map((s) => s.toLowerCase()) + .includes((argv.scope as string).toLowerCase()) + ) { + throw new Error( + `Invalid scope: ${argv.scope}. Please use one of ${Object.values( + SettingScope, + ) + .map((s) => s.toLowerCase()) + .join(', ')}.`, + ); + } + return true; + }), + handler: (argv) => { + handleEnable({ name: argv['name'] as string, - scope: argv['scope'] as SettingScope, + scope: argv['scope'] as string, }); }, }; diff --git a/packages/cli/src/commands/extensions/examples/context/QWEN.md b/packages/cli/src/commands/extensions/examples/context/QWEN.md new file mode 100644 index 00000000..22f6bbce --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/context/QWEN.md @@ -0,0 +1,8 @@ +# Ink Library Screen Reader Guidance + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +## General Principles + +Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on and to provide semantic meaning to screen readers. diff --git a/packages/cli/src/commands/extensions/examples/context/qwen-extension.json b/packages/cli/src/commands/extensions/examples/context/qwen-extension.json new file mode 100644 index 00000000..64f3f535 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/context/qwen-extension.json @@ -0,0 +1,4 @@ +{ + "name": "context-example", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml new file mode 100644 index 00000000..87d95754 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml @@ -0,0 +1,6 @@ +prompt = """ +Please summarize the findings for the pattern `{{args}}`. + +Search Results: +!{grep -r {{args}} .} +""" diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json b/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json new file mode 100644 index 00000000..d973ab8f --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/qwen-extension.json @@ -0,0 +1,4 @@ +{ + "name": "custom-commands", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json b/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json new file mode 100644 index 00000000..5023fb7a --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/exclude-tools/qwen-extension.json @@ -0,0 +1,5 @@ +{ + "name": "excludeTools", + "version": "1.0.0", + "excludeTools": ["run_shell_command(rm -rf)"] +} diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/example.ts b/packages/cli/src/commands/extensions/examples/mcp-server/example.ts new file mode 100644 index 00000000..21e01e17 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/example.ts @@ -0,0 +1,60 @@ +/** + * @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); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/package.json b/packages/cli/src/commands/extensions/examples/mcp-server/package.json new file mode 100644 index 00000000..d38f7ee9 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-server-example", + "version": "1.0.0", + "description": "Example MCP Server for Gemini CLI Extension", + "type": "module", + "main": "example.js", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "~5.4.5", + "@types/node": "^20.11.25" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0", + "zod": "^3.22.4" + } +} diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/qwen-extension.json b/packages/cli/src/commands/extensions/examples/mcp-server/qwen-extension.json new file mode 100644 index 00000000..62561dbf --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/qwen-extension.json @@ -0,0 +1,11 @@ +{ + "name": "mcp-server-example", + "version": "1.0.0", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}dist${/}example.js"], + "cwd": "${extensionPath}" + } + } +} diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json b/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json new file mode 100644 index 00000000..b94585ed --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist" + }, + "include": ["example.ts"] +} diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 859ed951..17a41d8d 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -4,10 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import { installCommand } from './install.js'; +import { describe, it, expect, vi, type MockInstance } from 'vitest'; +import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; +const mockInstallExtension = vi.hoisted(() => vi.fn()); +const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); +const mockStat = vi.hoisted(() => vi.fn()); + +vi.mock('../../config/extension.js', () => ({ + installExtension: mockInstallExtension, + requestConsentNonInteractive: mockRequestConsentNonInteractive, +})); + +vi.mock('../../utils/errors.js', () => ({ + getErrorMessage: vi.fn((error: Error) => error.message), +})); + +vi.mock('node:fs/promises', () => ({ + stat: mockStat, + default: { + stat: mockStat, + }, +})); + describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]) @@ -15,17 +35,109 @@ describe('extensions install command', () => { .command(installCommand) .fail(false); expect(() => validationParser.parse('install')).toThrow( - 'Either a git URL --source or a --path must be provided.', + 'Not enough non-option arguments: got 0, need at least 1', + ); + }); +}); + +describe('handleInstall', () => { + let consoleLogSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processSpy: MockInstance; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log'); + consoleErrorSpy = vi.spyOn(console, 'error'); + processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + }); + + afterEach(() => { + mockInstallExtension.mockClear(); + mockRequestConsentNonInteractive.mockClear(); + mockStat.mockClear(); + vi.resetAllMocks(); + }); + + it('should install an extension from a http source', async () => { + mockInstallExtension.mockResolvedValue('http-extension'); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Extension "http-extension" installed successfully and enabled.', ); }); - it('should fail if both git source and local path are provided', () => { - const validationParser = yargs([]) - .locale('en') - .command(installCommand) - .fail(false); - expect(() => - validationParser.parse('install --source some-url --path /some/path'), - ).toThrow('Arguments source and path are mutually exclusive'); + it('should install an extension from a https source', async () => { + mockInstallExtension.mockResolvedValue('https-extension'); + + await handleInstall({ + source: 'https://google.com', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Extension "https-extension" installed successfully and enabled.', + ); + }); + + it('should install an extension from a git source', async () => { + mockInstallExtension.mockResolvedValue('git-extension'); + + await handleInstall({ + source: 'git@some-url', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Extension "git-extension" installed successfully and enabled.', + ); + }); + + it('throws an error from an unknown source', async () => { + mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory')); + await handleInstall({ + source: 'test://google.com', + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.'); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should install an extension from a sso source', async () => { + mockInstallExtension.mockResolvedValue('sso-extension'); + + await handleInstall({ + source: 'sso://google.com', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Extension "sso-extension" installed successfully and enabled.', + ); + }); + + it('should install an extension from a local path', async () => { + mockInstallExtension.mockResolvedValue('local-extension'); + mockStat.mockResolvedValue({}); + await handleInstall({ + source: '/some/path', + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Extension "local-extension" installed successfully and enabled.', + ); + }); + + it('should throw an error if install extension fails', async () => { + mockInstallExtension.mockRejectedValue( + new Error('Install extension failed'), + ); + + await handleInstall({ source: 'git@some-url' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Install extension failed'); + expect(processSpy).toHaveBeenCalledWith(1); }); }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index af411c3d..2f1675ff 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -7,26 +7,56 @@ import type { CommandModule } from 'yargs'; import { installExtension, - type ExtensionInstallMetadata, + requestConsentNonInteractive, } from '../../config/extension.js'; - +import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; +import { stat } from 'node:fs/promises'; interface InstallArgs { - source?: string; - path?: string; + source: string; + ref?: string; + autoUpdate?: boolean; } export async function handleInstall(args: InstallArgs) { try { - const installMetadata: ExtensionInstallMetadata = { - source: (args.source || args.path) as string, - type: args.source ? 'git' : 'local', - }; - const extensionName = await installExtension(installMetadata); - console.log( - `Extension "${extensionName}" installed successfully and enabled.`, + let installMetadata: ExtensionInstallMetadata; + const { source } = args; + if ( + source.startsWith('http://') || + source.startsWith('https://') || + source.startsWith('git@') || + source.startsWith('sso://') + ) { + installMetadata = { + source, + type: 'git', + ref: args.ref, + autoUpdate: args.autoUpdate, + }; + } else { + if (args.ref || args.autoUpdate) { + throw new Error( + '--ref and --auto-update are not applicable for local extensions.', + ); + } + try { + await stat(source); + installMetadata = { + source, + type: 'local', + }; + } catch { + throw new Error('Install source not found.'); + } + } + + const name = await installExtension( + installMetadata, + requestConsentNonInteractive, ); + console.log(`Extension "${name}" installed successfully and enabled.`); } catch (error) { console.error(getErrorMessage(error)); process.exit(1); @@ -34,31 +64,34 @@ export async function handleInstall(args: InstallArgs) { } export const installCommand: CommandModule = { - command: 'install [--source | --path ]', - describe: 'Installs an extension from a git repository or a local path.', + command: 'install ', + describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs - .option('source', { - describe: 'The git URL of the extension to install.', + .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('path', { - describe: 'Path to a local extension directory.', - type: 'string', + .option('auto-update', { + describe: 'Enable auto-update for this extension.', + type: 'boolean', }) - .conflicts('source', 'path') .check((argv) => { - if (!argv.source && !argv.path) { - throw new Error( - 'Either a git URL --source or a --path must be provided.', - ); + if (!argv.source) { + throw new Error('The source argument must be provided.'); } return true; }), handler: async (argv) => { await handleInstall({ - source: argv['source'] as string | undefined, - path: argv['path'] as string | undefined, + source: argv['source'] as string, + ref: argv['ref'] as string | undefined, + autoUpdate: argv['auto-update'] as boolean | undefined, }); }, }; diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts new file mode 100644 index 00000000..8104e42b --- /dev/null +++ b/packages/cli/src/commands/extensions/link.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + installExtension, + requestConsentNonInteractive, +} from '../../config/extension.js'; +import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; + +import { getErrorMessage } from '../../utils/errors.js'; + +interface InstallArgs { + path: string; +} + +export async function handleLink(args: InstallArgs) { + try { + const installMetadata: ExtensionInstallMetadata = { + source: args.path, + type: 'link', + }; + const extensionName = await installExtension( + installMetadata, + requestConsentNonInteractive, + ); + console.log( + `Extension "${extensionName}" linked successfully and enabled.`, + ); + } catch (error) { + console.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', + }) + .check((_) => true), + handler: async (argv) => { + await handleLink({ + path: argv['path'] as string, + }); + }, +}; diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 46110625..f6689a3c 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -17,7 +17,7 @@ export async function handleList() { } console.log( extensions - .map((extension, _): string => toOutputString(extension)) + .map((extension, _): string => toOutputString(extension, process.cwd())) .join('\n\n'), ); } catch (error) { diff --git a/packages/cli/src/commands/extensions/new.test.ts b/packages/cli/src/commands/extensions/new.test.ts new file mode 100644 index 00000000..62c9edce --- /dev/null +++ b/packages/cli/src/commands/extensions/new.test.ts @@ -0,0 +1,91 @@ +/** + * @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'); + +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(); + }); +}); diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts new file mode 100644 index 00000000..27f9c6dd --- /dev/null +++ b/packages/cli/src/commands/extensions/new.ts @@ -0,0 +1,109 @@ +/** + * @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 { getErrorMessage } from '../../utils/errors.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) { + try { + if (args.template) { + await copyDirectory(args.template, args.path); + console.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, 'qwen-extension.json'), + JSON.stringify(manifest, null, 2), + ); + console.log(`Successfully created new extension at ${args.path}.`); + } + console.log( + `You can install this using "qwen extensions link ${args.path}" to test it out.`, + ); + } catch (error) { + console.error(getErrorMessage(error)); + throw error; + } +} + +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({ + path: args['path'] as string, + template: args['template'] as string | undefined, + }); + }, +}; diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index 4dd68095..e2028458 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -11,9 +11,9 @@ import yargs from 'yargs'; describe('extensions uninstall command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]) - .locale('en') .command(uninstallCommand) - .fail(false); + .fail(false) + .locale('en'); expect(() => validationParser.parse('uninstall')).toThrow( 'Not enough non-option arguments: got 0, need at least 1', ); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index ff93b797..d7c13196 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -9,7 +9,7 @@ import { uninstallExtension } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; interface UninstallArgs { - name: string; + name: string; // can be extension name or source URL. } export async function handleUninstall(args: UninstallArgs) { @@ -28,7 +28,7 @@ export const uninstallCommand: CommandModule = { builder: (yargs) => yargs .positional('name', { - describe: 'The name of the extension to uninstall.', + describe: 'The name or source path of the extension to uninstall.', type: 'string', }) .check((argv) => { diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 43ac6de8..bb200e58 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -5,43 +5,147 @@ */ import type { CommandModule } from 'yargs'; -import { updateExtension } from '../../config/extension.js'; +import { + loadExtensions, + annotateActiveExtensions, + ExtensionStorage, + requestConsentNonInteractive, +} from '../../config/extension.js'; +import { + updateAllUpdatableExtensions, + type ExtensionUpdateInfo, + checkForAllExtensionUpdates, + updateExtension, +} from '../../config/extensions/update.js'; +import { checkForExtensionUpdate } from '../../config/extensions/github.js'; import { getErrorMessage } from '../../utils/errors.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js'; interface UpdateArgs { - name: string; + name?: string; + all?: boolean; } +const updateOutput = (info: ExtensionUpdateInfo) => + `Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`; + export async function handleUpdate(args: UpdateArgs) { - try { - // TODO(chrstnb): we should list extensions if the requested extension is not installed. - const updatedExtensionInfo = await updateExtension(args.name); - if (!updatedExtensionInfo) { - console.log(`Extension "${args.name}" failed to update.`); - return; + const workingDir = process.cwd(); + const extensionEnablementManager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + // Force enable named extensions, otherwise we will only update the enabled + // ones. + args.name ? [args.name] : [], + ); + const allExtensions = loadExtensions(extensionEnablementManager); + const extensions = annotateActiveExtensions( + allExtensions, + workingDir, + extensionEnablementManager, + ); + if (args.name) { + try { + const extension = extensions.find( + (extension) => extension.name === args.name, + ); + if (!extension) { + console.log(`Extension "${args.name}" not found.`); + return; + } + let updateState: ExtensionUpdateState | undefined; + if (!extension.installMetadata) { + console.log( + `Unable to install extension "${args.name}" due to missing install metadata`, + ); + return; + } + await checkForExtensionUpdate(extension, (newState) => { + updateState = newState; + }); + if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { + console.log(`Extension "${args.name}" is already up to date.`); + return; + } + // TODO(chrstnb): we should list extensions if the requested extension is not installed. + const updatedExtensionInfo = (await updateExtension( + extension, + workingDir, + requestConsentNonInteractive, + updateState, + () => {}, + ))!; + if ( + updatedExtensionInfo.originalVersion !== + updatedExtensionInfo.updatedVersion + ) { + console.log( + `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, + ); + } else { + console.log(`Extension "${args.name}" is already up to date.`); + } + } catch (error) { + console.error(getErrorMessage(error)); + } + } + if (args.all) { + try { + const extensionState = new Map(); + await checkForAllExtensionUpdates(extensions, (action) => { + if (action.type === 'SET_STATE') { + extensionState.set(action.payload.name, { + status: action.payload.state, + processed: true, // No need to process as we will force the update. + }); + } + }); + let updateInfos = await updateAllUpdatableExtensions( + workingDir, + requestConsentNonInteractive, + extensions, + extensionState, + () => {}, + ); + updateInfos = updateInfos.filter( + (info) => info.originalVersion !== info.updatedVersion, + ); + if (updateInfos.length === 0) { + console.log('No extensions to update.'); + return; + } + console.log(updateInfos.map((info) => updateOutput(info)).join('\n')); + } catch (error) { + console.error(getErrorMessage(error)); } - console.log( - `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, - ); - } catch (error) { - console.error(getErrorMessage(error)); - process.exit(1); } } export const updateCommand: CommandModule = { - command: 'update ', - describe: 'Updates an extension.', + 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', }) - .check((_argv) => true), + .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({ - name: argv['name'] as string, + name: argv['name'] as string | undefined, + all: argv['all'] as boolean | undefined, }); }, }; diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index fc1ffb64..357235b5 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -13,6 +13,16 @@ vi.mock('fs/promises', () => ({ 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 { @@ -26,15 +36,20 @@ const mockedLoadSettings = loadSettings as vi.Mock; describe('mcp add command', () => { let parser: yargs.Argv; let mockSetValue: vi.Mock; + let mockConsoleError: vi.Mock; beforeEach(() => { vi.resetAllMocks(); const yargsInstance = yargs([]).command(addCommand); parser = yargsInstance; mockSetValue = vi.fn(); + mockConsoleError = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(mockConsoleError); mockedLoadSettings.mockReturnValue({ forScope: () => ({ settings: {} }), setValue: mockSetValue, + workspace: { path: '/path/to/project' }, + user: { path: '/home/user' }, }); }); @@ -119,4 +134,218 @@ describe('mcp add command', () => { }, ); }); + + 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) => never); + + await expect( + parser.parseAsync(`add ${serverName} ${command}`), + ).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).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) => never); + + await expect( + parser.parseAsync(`add --scope project ${serverName} ${command}`), + ).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).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(mockConsoleError).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, + }), + }), + ); + }); + }); }); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 29fe15c6..254bea60 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -36,9 +36,19 @@ async function addMcpServer( includeTools, excludeTools, } = options; + + const settings = loadSettings(process.cwd()); + const inHome = settings.workspace.path === settings.user.path; + + if (scope === 'project' && inHome) { + console.error( + 'Error: Please use --scope user to edit settings in the home directory.', + ); + process.exit(1); + } + const settingsScope = scope === 'user' ? SettingScope.User : SettingScope.Workspace; - const settings = loadSettings(process.cwd()); let newServer: Partial = {}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 60a62834..4d9bb083 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -7,7 +7,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings } from '../../config/settings.js'; -import { loadExtensions } from '../../config/extension.js'; +import { ExtensionStorage, loadExtensions } from '../../config/extension.js'; import { createTransport } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -16,6 +16,9 @@ vi.mock('../../config/settings.js', () => ({ })); vi.mock('../../config/extension.js', () => ({ loadExtensions: vi.fn(), + ExtensionStorage: { + getUserExtensionsDir: vi.fn(), + }, })); vi.mock('@qwen-code/qwen-code-core', () => ({ createTransport: vi.fn(), @@ -29,11 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json', getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash', })), - GEMINI_CONFIG_DIR: '.qwen', + QWEN_CONFIG_DIR: '.qwen', getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), })); vi.mock('@modelcontextprotocol/sdk/client/index.js'); +const mockedExtensionStorage = ExtensionStorage as vi.Mock; const mockedLoadSettings = loadSettings as vi.Mock; const mockedLoadExtensions = loadExtensions as vi.Mock; const mockedCreateTransport = createTransport as vi.Mock; @@ -69,6 +73,9 @@ describe('mcp list command', () => { MockedClient.mockImplementation(() => mockClient); mockedCreateTransport.mockResolvedValue(mockTransport); mockedLoadExtensions.mockReturnValue([]); + mockedExtensionStorage.getUserExtensionsDir.mockReturnValue( + '/mocked/extensions/dir', + ); }); afterEach(() => { diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 0d246c51..09d9bf63 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -10,7 +10,8 @@ import { loadSettings } from '../../config/settings.js'; import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { loadExtensions } from '../../config/extension.js'; +import { ExtensionStorage, loadExtensions } from '../../config/extension.js'; +import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js'; const COLOR_GREEN = '\u001b[32m'; const COLOR_YELLOW = '\u001b[33m'; @@ -20,8 +21,10 @@ const RESET_COLOR = '\u001b[0m'; async function getMcpServersFromConfig(): Promise< Record > { - const settings = loadSettings(process.cwd()); - const extensions = loadExtensions(process.cwd()); + const settings = loadSettings(); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); const mcpServers = { ...(settings.merged.mcpServers || {}) }; for (const extension of extensions) { Object.entries(extension.config.mcpServers || {}).forEach( diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index e05478e3..bcaa5ad4 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -17,7 +17,7 @@ async function removeMcpServer( const { scope } = options; const settingsScope = scope === 'user' ? SettingScope.User : SettingScope.Workspace; - const settings = loadSettings(process.cwd()); + const settings = loadSettings(); const existingSettings = settings.forScope(settingsScope).settings; const mcpServers = existingSettings.mcpServers || {}; diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index 81574d9f..b69c5fb0 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -10,18 +10,22 @@ import { validateAuthMethod } from './auth.js'; vi.mock('./settings.js', () => ({ loadEnvironment: vi.fn(), + loadSettings: vi.fn().mockReturnValue({ + merged: vi.fn().mockReturnValue({}), + }), })); describe('validateAuthMethod', () => { - const originalEnv = process.env; - beforeEach(() => { vi.resetModules(); - process.env = {}; + 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(() => { - process.env = originalEnv; + vi.unstubAllEnvs(); }); it('should return null for LOGIN_WITH_GOOGLE', () => { @@ -34,11 +38,12 @@ describe('validateAuthMethod', () => { describe('USE_GEMINI', () => { it('should return null if GEMINI_API_KEY is set', () => { - process.env['GEMINI_API_KEY'] = 'test-key'; + vi.stubEnv('GEMINI_API_KEY', 'test-key'); expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull(); }); it('should return an error message if GEMINI_API_KEY is not set', () => { + vi.stubEnv('GEMINI_API_KEY', undefined); expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe( 'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!', ); @@ -47,17 +52,19 @@ describe('validateAuthMethod', () => { describe('USE_VERTEX_AI', () => { it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => { - process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; - process.env['GOOGLE_CLOUD_LOCATION'] = 'test-location'; + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project'); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location'); expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull(); }); it('should return null if GOOGLE_API_KEY is set', () => { - process.env['GOOGLE_API_KEY'] = 'test-api-key'; + vi.stubEnv('GOOGLE_API_KEY', 'test-api-key'); expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull(); }); it('should return an error message if no required environment variables are set', () => { + vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined); expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe( 'When using Vertex AI, you must specify either:\n' + '• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' + diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 216066f3..dfc0d50b 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -5,10 +5,10 @@ */ import { AuthType } from '@qwen-code/qwen-code-core'; -import { loadEnvironment } from './settings.js'; +import { loadEnvironment, loadSettings } from './settings.js'; -export const validateAuthMethod = (authMethod: string): string | null => { - loadEnvironment(); +export function validateAuthMethod(authMethod: string): string | null { + loadEnvironment(loadSettings().merged); if ( authMethod === AuthType.LOGIN_WITH_GOOGLE || authMethod === AuthType.CLOUD_SHELL @@ -53,7 +53,7 @@ export const validateAuthMethod = (authMethod: string): string | null => { } return 'Invalid auth method selected.'; -}; +} export const setOpenAIApiKey = (apiKey: string): void => { process.env['OPENAI_API_KEY'] = apiKey; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 5abf181e..dc6c3464 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -4,26 +4,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; -import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core'; +import { + ShellTool, + EditTool, + WriteFileTool, + DEFAULT_QWEN_MODEL, + OutputFormat, +} from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; -import type { Extension } from './extension.js'; +import { ExtensionStorage, type Extension } from './extension.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; vi.mock('./trustedFolders.js', () => ({ - isWorkspaceTrusted: vi.fn().mockReturnValue(true), // Default to trusted + isWorkspaceTrusted: vi + .fn() + .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted })); vi.mock('fs', async (importOriginal) => { @@ -99,11 +100,11 @@ vi.mock('@qwen-code/qwen-code-core', async () => { ), DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, - respectGeminiIgnore: true, + respectQwenIgnore: true, }, DEFAULT_FILE_FILTERING_OPTIONS: { respectGitIgnore: true, - respectGeminiIgnore: true, + respectQwenIgnore: true, }, }; }); @@ -205,6 +206,136 @@ describe('parseArguments', () => { expect(argv.prompt).toBeUndefined(); }); + it('should convert positional query argument to prompt by default', async () => { + process.argv = ['node', 'script.js', 'Hi Gemini']; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe('Hi Gemini'); + expect(argv.prompt).toBe('Hi Gemini'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should map @path to prompt (one-shot) when it starts with @', async () => { + process.argv = ['node', 'script.js', '@path ./file.md']; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe('@path ./file.md'); + expect(argv.prompt).toBe('@path ./file.md'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should map @path to prompt even when config flags are present', async () => { + // @path queries should now go to one-shot mode regardless of other flags + process.argv = [ + 'node', + 'script.js', + '@path', + './file.md', + '--model', + 'gemini-1.5-pro', + ]; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe('@path ./file.md'); + expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot + expect(argv.promptInteractive).toBeUndefined(); + expect(argv.model).toBe('gemini-1.5-pro'); + }); + + it('maps unquoted positional @path + arg to prompt (one-shot)', async () => { + // Simulate: gemini @path ./file.md + process.argv = ['node', 'script.js', '@path', './file.md']; + const argv = await parseArguments({} as Settings); + // After normalization, query is a single string + expect(argv.query).toBe('@path ./file.md'); + // And it's mapped to one-shot prompt when no -p/-i flags are set + expect(argv.prompt).toBe('@path ./file.md'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should handle multiple @path arguments in a single command (one-shot)', async () => { + // Simulate: gemini @path ./file1.md @path ./file2.md + process.argv = [ + 'node', + 'script.js', + '@path', + './file1.md', + '@path', + './file2.md', + ]; + const argv = await parseArguments({} as Settings); + // After normalization, all arguments are joined with spaces + expect(argv.query).toBe('@path ./file1.md @path ./file2.md'); + // And it's mapped to one-shot prompt + expect(argv.prompt).toBe('@path ./file1.md @path ./file2.md'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should handle mixed quoted and unquoted @path arguments (one-shot)', async () => { + // Simulate: gemini "@path ./file1.md" @path ./file2.md "additional text" + process.argv = [ + 'node', + 'script.js', + '@path ./file1.md', + '@path', + './file2.md', + 'additional text', + ]; + const argv = await parseArguments({} as Settings); + // After normalization, all arguments are joined with spaces + expect(argv.query).toBe( + '@path ./file1.md @path ./file2.md additional text', + ); + // And it's mapped to one-shot prompt + expect(argv.prompt).toBe( + '@path ./file1.md @path ./file2.md additional text', + ); + expect(argv.promptInteractive).toBeUndefined(); + }); + + it('should map @path to prompt with ambient flags (debug, telemetry)', async () => { + // Ambient flags like debug, telemetry should NOT affect routing + process.argv = [ + 'node', + 'script.js', + '@path', + './file.md', + '--debug', + '--telemetry', + ]; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe('@path ./file.md'); + expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot + expect(argv.promptInteractive).toBeUndefined(); + expect(argv.debug).toBe(true); + expect(argv.telemetry).toBe(true); + }); + + it('should map any @command to prompt (one-shot)', async () => { + // Test that all @commands now go to one-shot mode + const testCases = [ + '@path ./file.md', + '@include src/', + '@search pattern', + '@web query', + '@git status', + ]; + + for (const testQuery of testCases) { + process.argv = ['node', 'script.js', testQuery]; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe(testQuery); + expect(argv.prompt).toBe(testQuery); + expect(argv.promptInteractive).toBeUndefined(); + } + }); + + it('should handle @command with leading whitespace', async () => { + // Test that trim() + routing handles leading whitespace correctly + process.argv = ['node', 'script.js', ' @path ./file.md']; + const argv = await parseArguments({} as Settings); + expect(argv.query).toBe(' @path ./file.md'); + expect(argv.prompt).toBe(' @path ./file.md'); + expect(argv.promptInteractive).toBeUndefined(); + }); + it('should throw an error when both --yolo and --approval-mode are used together', async () => { process.argv = [ 'node', @@ -297,6 +428,34 @@ describe('parseArguments', () => { mockExit.mockRestore(); mockConsoleError.mockRestore(); }); + + 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({} as Settings); + 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({} as Settings); + 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({} as Settings); + expect(argv.extensions).toEqual(['ext1', 'ext2']); + }); }); describe('loadCliConfig', () => { @@ -318,7 +477,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js', '--show-memory-usage']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getShowMemoryUsage()).toBe(true); }); @@ -326,7 +494,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getShowMemoryUsage()).toBe(false); }); @@ -334,7 +511,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { ui: { showMemoryUsage: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getShowMemoryUsage()).toBe(false); }); @@ -342,7 +528,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js', '--show-memory-usage']; const argv = await parseArguments({} as Settings); const settings: Settings = { ui: { showMemoryUsage: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getShowMemoryUsage()).toBe(true); }); @@ -376,7 +571,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getProxy()).toBeFalsy(); }); @@ -417,7 +621,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getProxy()).toBe(expected); }); }); @@ -426,7 +639,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getProxy()).toBe('http://localhost:7890'); }); @@ -435,7 +657,16 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getProxy()).toBe('http://localhost:7890'); }); }); @@ -460,7 +691,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(false); }); @@ -468,7 +708,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--telemetry']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -476,7 +725,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--no-telemetry']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(false); }); @@ -484,7 +742,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -492,7 +759,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(false); }); @@ -500,7 +776,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--telemetry']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -508,7 +793,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--no-telemetry']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(false); }); @@ -518,7 +812,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.example.com' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpEndpoint()).toBe( 'http://settings.example.com', ); @@ -535,7 +838,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.example.com' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpEndpoint()).toBe('http://cli.example.com'); }); @@ -543,7 +855,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317'); }); @@ -553,7 +874,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { target: ServerConfig.DEFAULT_TELEMETRY_TARGET }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryTarget()).toBe( ServerConfig.DEFAULT_TELEMETRY_TARGET, ); @@ -565,7 +895,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { target: ServerConfig.DEFAULT_TELEMETRY_TARGET }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -573,7 +912,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryTarget()).toBe( ServerConfig.DEFAULT_TELEMETRY_TARGET, ); @@ -583,7 +931,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -591,7 +948,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--telemetry-log-prompts']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); @@ -599,7 +965,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js', '--no-telemetry-log-prompts']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -607,7 +982,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); @@ -617,7 +1001,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { otlpProtocol: 'http' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -627,7 +1020,16 @@ describe('loadCliConfig telemetry', () => { const settings: Settings = { telemetry: { otlpProtocol: 'grpc' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -635,7 +1037,16 @@ describe('loadCliConfig telemetry', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); }); @@ -712,7 +1123,17 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }, ]; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'session-id', argv); + await loadCliConfig( + settings, + extensions, + + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'session-id', + argv, + ); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], @@ -723,10 +1144,11 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { '/path/to/ext3/context1.md', '/path/to/ext3/context2.md', ], + true, 'tree', { respectGitIgnore: false, - respectGeminiIgnore: true, + respectQwenIgnore: true, }, undefined, // maxDirs ); @@ -783,73 +1205,20 @@ describe('mergeMcpServers', () => { const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig( + settings, + extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(settings).toEqual(originalSettings); }); }); -describe('loadCliConfig systemPromptMappings', () => { - it('should use default systemPromptMappings when not provided in settings', async () => { - const mockSettings: Settings = { - theme: 'dark', - }; - const mockExtensions: Extension[] = []; - const mockSessionId = 'test-session'; - const mockArgv: CliArgs = { - model: 'test-model', - } as CliArgs; - - const config = await loadCliConfig( - mockSettings, - mockExtensions, - mockSessionId, - mockArgv, - ); - - expect(config.getSystemPromptMappings()).toEqual([ - { - baseUrls: [ - 'https://dashscope.aliyuncs.com/compatible-mode/v1/', - 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/', - ], - modelNames: ['qwen3-coder-plus'], - template: - 'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}', - }, - ]); - }); - - it('should use custom systemPromptMappings when provided in settings', async () => { - const customSystemPromptMappings = [ - { - baseUrls: ['https://custom-api.com'], - modelNames: ['custom-model'], - template: 'Custom template', - }, - ]; - const mockSettings: Settings = { - theme: 'dark', - systemPromptMappings: customSystemPromptMappings, - }; - const mockExtensions: Extension[] = []; - const mockSessionId = 'test-session'; - const mockArgv: CliArgs = { - model: 'test-model', - } as CliArgs; - - const config = await loadCliConfig( - mockSettings, - mockExtensions, - mockSessionId, - mockArgv, - ); - - expect(config.getSystemPromptMappings()).toEqual( - customSystemPromptMappings, - ); - }); -}); - describe('mergeExcludeTools', () => { const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name]; const originalIsTTY = process.stdin.isTTY; @@ -889,6 +1258,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -916,6 +1289,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -952,6 +1329,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -970,6 +1351,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -985,6 +1370,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -999,6 +1388,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1026,6 +1419,10 @@ describe('mergeExcludeTools', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1051,7 +1448,16 @@ describe('mergeExcludeTools', () => { const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig( + settings, + extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(settings).toEqual(originalSettings); }); }); @@ -1077,6 +1483,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1103,6 +1513,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1129,6 +1543,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1155,6 +1573,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1181,6 +1603,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1200,6 +1626,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1231,6 +1661,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1258,6 +1692,10 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1280,11 +1718,14 @@ describe('Approval mode tool exclusion logic', () => { const settings: Settings = {}; const extensions: Extension[] = []; - await expect( loadCliConfig( settings, extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + invalidArgv.extensions, + ), 'test-session', invalidArgv as CliArgs, ), @@ -1320,7 +1761,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig( + baseSettings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1332,7 +1782,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server1', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig( + baseSettings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1348,7 +1807,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server3', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig( + baseSettings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server3: { url: 'http://localhost:8082' }, @@ -1365,7 +1833,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server4', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig( + baseSettings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1374,7 +1851,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 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({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig( + baseSettings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({}); }); @@ -1385,7 +1871,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, @@ -1399,7 +1894,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server3: { url: 'http://localhost:8082' }, }); @@ -1415,7 +1919,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server1', 'server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, }); @@ -1436,7 +1949,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1459,7 +1981,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { excluded: ['server3'], // Should be ignored }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, @@ -1488,6 +2019,10 @@ describe('loadCliConfig extensions', () => { const config = await loadCliConfig( settings, mockExtensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1504,6 +2039,10 @@ describe('loadCliConfig extensions', () => { const config = await loadCliConfig( settings, mockExtensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1522,6 +2061,10 @@ describe('loadCliConfig model selection', () => { }, }, [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1537,11 +2080,15 @@ describe('loadCliConfig model selection', () => { // No model set. }, [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); - expect(config.getModel()).toBe('coder-model'); + expect(config.getModel()).toBe(DEFAULT_QWEN_MODEL); }); it('always prefers model from argvs', async () => { @@ -1554,6 +2101,10 @@ describe('loadCliConfig model selection', () => { }, }, [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1569,6 +2120,10 @@ describe('loadCliConfig model selection', () => { // No model provided via settings. }, [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), 'test-session', argv, ); @@ -1577,40 +2132,6 @@ describe('loadCliConfig model selection', () => { }); }); -describe('loadCliConfig folderTrustFeature', () => { - const originalArgv = process.argv; - - beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); - vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); - }); - - afterEach(() => { - process.argv = originalArgv; - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - }); - - it('should be false by default', async () => { - process.argv = ['node', 'script.js']; - const settings: Settings = {}; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getFolderTrustFeature()).toBe(false); - }); - - it('should be true when settings.folderTrustFeature is true', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { - security: { folderTrust: { featureEnabled: true } }, - }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getFolderTrustFeature()).toBe(true); - }); -}); - describe('loadCliConfig folderTrust', () => { const originalArgv = process.argv; @@ -1626,65 +2147,68 @@ describe('loadCliConfig folderTrust', () => { vi.restoreAllMocks(); }); - it('should be false if folderTrustFeature is false and folderTrust is false', async () => { + it('should be false when folderTrust is false', async () => { process.argv = ['node', 'script.js']; const settings: Settings = { security: { folderTrust: { - featureEnabled: false, enabled: false, }, }, }; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getFolderTrust()).toBe(false); }); - it('should be false if folderTrustFeature is true and folderTrust is false', async () => { + it('should be true when folderTrust is true', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { security: { folderTrust: { - featureEnabled: true, - enabled: false, - }, - }, - }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getFolderTrust()).toBe(false); - }); - - it('should be false if folderTrustFeature is false and folderTrust is true', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { - security: { - folderTrust: { - featureEnabled: false, enabled: true, }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getFolderTrust()).toBe(false); - }); - - it('should be true when folderTrustFeature is true and folderTrust is true', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { - security: { - folderTrust: { - featureEnabled: true, - enabled: true, - }, - }, - }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getFolderTrust()).toBe(true); }); + + it('should be false by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = {}; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getFolderTrust()).toBe(false); + }); }); describe('loadCliConfig with includeDirectories', () => { @@ -1723,7 +2247,16 @@ describe('loadCliConfig with includeDirectories', () => { ], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); const expected = [ mockCwd, path.resolve(path.sep, 'cli', 'path1'), @@ -1766,7 +2299,16 @@ describe('loadCliConfig chatCompression', () => { }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getChatCompression()).toEqual({ contextPercentageThreshold: 0.5, }); @@ -1776,7 +2318,16 @@ describe('loadCliConfig chatCompression', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getChatCompression()).toBeUndefined(); }); }); @@ -1796,29 +2347,146 @@ describe('loadCliConfig useRipgrep', () => { vi.restoreAllMocks(); }); - it('should be false by default when useRipgrep is not set in settings', async () => { + it('should be true by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getUseRipgrep()).toBe(false); - }); - - it('should be true when useRipgrep is set to true in settings', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getUseRipgrep()).toBe(true); }); - it('should be false when useRipgrep is explicitly set to false in settings', async () => { + it('should be false when useRipgrep is set to false in settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { useRipgrep: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { tools: { useRipgrep: true } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getUseRipgrep()).toBe(true); + }); +}); + +describe('screenReader configuration', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + }); + + afterEach(() => { + process.argv = originalArgv; + 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({} as Settings); + const settings: Settings = { + ui: { accessibility: { screenReader: true } }, + }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { + ui: { accessibility: { screenReader: false } }, + }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { + ui: { accessibility: { screenReader: false } }, + }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = {}; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getScreenReader()).toBe(false); + }); }); describe('loadCliConfig tool exclusions', () => { @@ -1844,40 +2512,76 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); - expect(config.getExcludeTools()).not.toContain(ShellTool.Name); - expect(config.getExcludeTools()).not.toContain(EditTool.Name); - expect(config.getExcludeTools()).not.toContain(WriteFileTool.Name); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); }); 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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); - expect(config.getExcludeTools()).not.toContain(ShellTool.Name); - expect(config.getExcludeTools()).not.toContain(EditTool.Name); - expect(config.getExcludeTools()).not.toContain(WriteFileTool.Name); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); }); 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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); - expect(config.getExcludeTools()).toContain(ShellTool.Name); - expect(config.getExcludeTools()).toContain(EditTool.Name); - expect(config.getExcludeTools()).toContain(WriteFileTool.Name); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('run_shell_command'); + expect(config.getExcludeTools()).toContain('edit'); + expect(config.getExcludeTools()).toContain('write_file'); }); it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); - expect(config.getExcludeTools()).not.toContain(ShellTool.Name); - expect(config.getExcludeTools()).not.toContain(EditTool.Name); - expect(config.getExcludeTools()).not.toContain(WriteFileTool.Name); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); }); }); @@ -1903,7 +2607,16 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); @@ -1911,7 +2624,16 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); @@ -1919,7 +2641,16 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); @@ -1927,9 +2658,79 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); + + it('should not be interactive if positional prompt words are provided with other flags', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--model', 'gemini-1.5-pro', 'Hello']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(false); + }); + + it('should not be interactive if positional prompt words are provided with multiple flags', async () => { + process.stdin.isTTY = true; + process.argv = [ + 'node', + 'script.js', + '--model', + 'gemini-1.5-pro', + '--yolo', + 'Hello world', + ]; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(false); + // Verify the question is preserved for one-shot execution + expect(argv.prompt).toBe('Hello world'); + expect(argv.promptInteractive).toBeUndefined(); + }); + + 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-1.5-pro']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); + }); }); describe('loadCliConfig approval mode', () => { @@ -1952,74 +2753,164 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set PLAN approval mode when --approval-mode=plan', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should use approval mode from settings when CLI flags are not provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { approvalMode: 'plan' }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const settings: Settings = { tools: { approvalMode: 'plan' } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); }); it('should normalize approval mode values from settings', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { approvalMode: 'AutoEdit' }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const settings: Settings = { tools: { approvalMode: 'AutoEdit' } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should throw when approval mode in settings is invalid', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const settings: Settings = { approvalMode: 'invalid_mode' }; + const settings: Settings = { tools: { approvalMode: 'invalid_mode' } }; await expect( - loadCliConfig(settings, [], 'test-session', argv), + loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ), ).rejects.toThrow( 'Invalid approval mode: invalid_mode. Valid values are: plan, default, auto-edit, yolo', ); @@ -2032,61 +2923,127 @@ describe('loadCliConfig approval mode', () => { const argv = await parseArguments({} as Settings); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + 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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should allow PLAN approval mode in untrusted folders', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); }); }); }); -describe('loadCliConfig trustedFolder', () => { +describe('loadCliConfig fileFiltering', () => { const originalArgv = process.argv; beforeEach(() => { @@ -2102,120 +3059,455 @@ describe('loadCliConfig trustedFolder', () => { vi.restoreAllMocks(); }); - const testCases = [ - // Cases where folderTrustFeature is false (feature disabled) + const testCases: Array<{ + property: keyof NonNullable; + getter: (config: ServerConfig.Config) => boolean; + value: boolean; + }> = [ { - folderTrustFeature: false, - folderTrust: true, - isWorkspaceTrusted: true, - expectedFolderTrust: false, - expectedIsTrustedFolder: true, - description: - 'feature disabled, folderTrust true, workspace trusted -> behave as trusted', + property: 'disableFuzzySearch', + getter: (c) => c.getFileFilteringDisableFuzzySearch(), + value: true, }, { - folderTrustFeature: false, - folderTrust: true, - isWorkspaceTrusted: false, - expectedFolderTrust: false, - expectedIsTrustedFolder: true, - description: - 'feature disabled, folderTrust true, workspace not trusted -> behave as trusted', + property: 'disableFuzzySearch', + getter: (c) => c.getFileFilteringDisableFuzzySearch(), + value: false, }, { - folderTrustFeature: false, - folderTrust: false, - isWorkspaceTrusted: true, - expectedFolderTrust: false, - expectedIsTrustedFolder: true, - description: - 'feature disabled, folderTrust false, workspace trusted -> behave as trusted', - }, - - // Cases where folderTrustFeature is true but folderTrust setting is false - { - folderTrustFeature: true, - folderTrust: false, - isWorkspaceTrusted: true, - expectedFolderTrust: false, - expectedIsTrustedFolder: true, - description: - 'feature on, folderTrust false, workspace trusted -> behave as trusted', + property: 'respectGitIgnore', + getter: (c) => c.getFileFilteringRespectGitIgnore(), + value: true, }, { - folderTrustFeature: true, - folderTrust: false, - isWorkspaceTrusted: false, - expectedFolderTrust: false, - expectedIsTrustedFolder: true, - description: - 'feature on, folderTrust false, workspace not trusted -> behave as trusted', - }, - - // Cases where feature is fully enabled (folderTrustFeature and folderTrust are true) - { - folderTrustFeature: true, - folderTrust: true, - isWorkspaceTrusted: true, - expectedFolderTrust: true, - expectedIsTrustedFolder: true, - description: - 'feature on, folderTrust on, workspace trusted -> is trusted', + property: 'respectGitIgnore', + getter: (c) => c.getFileFilteringRespectGitIgnore(), + value: false, }, { - folderTrustFeature: true, - folderTrust: true, - isWorkspaceTrusted: false, - expectedFolderTrust: true, - expectedIsTrustedFolder: false, - description: - 'feature on, folderTrust on, workspace NOT trusted -> is NOT trusted', + property: 'respectQwenIgnore', + getter: (c) => c.getFileFilteringRespectQwenIgnore(), + value: true, }, { - folderTrustFeature: true, - folderTrust: true, - isWorkspaceTrusted: undefined, - expectedFolderTrust: true, - expectedIsTrustedFolder: undefined, - description: - 'feature on, folderTrust on, workspace trust unknown -> is unknown', + property: 'respectQwenIgnore', + getter: (c) => c.getFileFilteringRespectQwenIgnore(), + value: false, + }, + { + property: 'enableRecursiveFileSearch', + getter: (c) => c.getEnableRecursiveFileSearch(), + value: true, + }, + { + property: 'enableRecursiveFileSearch', + getter: (c) => c.getEnableRecursiveFileSearch(), + value: false, }, ]; - for (const { - folderTrustFeature, - folderTrust, - isWorkspaceTrusted: mockTrustValue, - expectedFolderTrust, - expectedIsTrustedFolder, - description, - } of testCases) { - it(`should be correct for: ${description}`, async () => { - (isWorkspaceTrusted as Mock).mockImplementation((settings: Settings) => { - const folderTrustFeature = - settings.security?.folderTrust?.featureEnabled ?? false; - const folderTrustSetting = - settings.security?.folderTrust?.enabled ?? true; - const folderTrustEnabled = folderTrustFeature && folderTrustSetting; - - if (!folderTrustEnabled) { - return true; - } - return mockTrustValue; // This is the part that comes from the test case - }); - const argv = await parseArguments({} as Settings); + it.each(testCases)( + 'should pass $property from settings to config when $value', + async ({ property, getter, value }) => { const settings: Settings = { - security: { - folderTrust: { - featureEnabled: folderTrustFeature, - enabled: folderTrust, - }, + context: { + fileFiltering: { [property]: value }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - - expect(config.getFolderTrust()).toBe(expectedFolderTrust); - expect(config.isTrustedFolder()).toBe(expectedIsTrustedFolder); - }); - } + const argv = await parseArguments(settings); + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(getter(config)).toBe(value); + }, + ); +}); + +describe('Output format', () => { + it('should default to TEXT', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + { output: { format: OutputFormat.JSON } }, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + { output: { format: OutputFormat.JSON } }, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(OutputFormat.JSON); + }); + + it('should error on invalid --output-format argument', async () => { + process.argv = ['node', 'script.js', '--output-format', 'yaml']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Invalid values:'), + ); + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); +}); + +describe('parseArguments with positional prompt', () => { + const originalArgv = process.argv; + + 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(() => {}); + + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both a positional prompt and the --prompt (-p) flag together', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('should correctly parse a positional prompt to query field', async () => { + process.argv = ['node', 'script.js', 'positional', 'prompt']; + const argv = await parseArguments({} as Settings); + 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 correctly parse a prompt from the --prompt flag', async () => { + process.argv = ['node', 'script.js', '--prompt', 'test prompt']; + const argv = await parseArguments({} as Settings); + expect(argv.prompt).toBe('test prompt'); + }); +}); + +describe('Telemetry configuration via environment variables', () => { + it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { + vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { telemetry: { enabled: false } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { target: 'local' } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { target: 'gcp' } }; + await expect( + loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { + telemetry: { otlpEndpoint: 'http://settings.com' }, + }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { logPrompts: true } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { + telemetry: { outfile: '/settings/telemetry.log' }, + }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { useCollector: false } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { enabled: true } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const settings: Settings = { telemetry: { target: 'local' } }; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + { telemetry: { enabled: true } }, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + {}, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + '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({} as Settings); + const config = await loadCliConfig( + { telemetry: { logPrompts: true } }, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + expect(config.getTelemetryLogPromptsEnabled()).toBe(false); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index da06e35c..7296ff43 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -5,16 +5,16 @@ */ import type { - ConfigParameters, FileFilteringOptions, MCPServerConfig, - TelemetryTarget, + OutputFormat, } from '@qwen-code/qwen-code-core'; +import { extensionsCommand } from '../commands/extensions.js'; import { ApprovalMode, Config, - DEFAULT_GEMINI_EMBEDDING_MODEL, - DEFAULT_GEMINI_MODEL, + DEFAULT_QWEN_MODEL, + DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, EditTool, FileDiscoveryService, @@ -23,24 +23,26 @@ import { setGeminiMdFilename as setServerGeminiMdFilename, ShellTool, WriteFileTool, + resolveTelemetrySettings, + FatalConfigError, } from '@qwen-code/qwen-code-core'; -import * as fs from 'node:fs'; -import { homedir } from 'node:os'; -import * as path from 'node:path'; -import process from 'node:process'; -import { hideBin } from 'yargs/helpers'; -import yargs from 'yargs/yargs'; -import { extensionsCommand } from '../commands/extensions.js'; -import { mcpCommand } from '../commands/mcp.js'; import type { Settings } from './settings.js'; +import yargs, { type Argv } from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; import { resolvePath } from '../utils/resolvePath.js'; import { getCliVersion } from '../utils/version.js'; import type { Extension } from './extension.js'; import { annotateActiveExtensions } from './extension.js'; import { loadSandboxConfig } from './sandboxConfig.js'; +import { appEvents } from '../utils/events.js'; +import { mcpCommand } from '../commands/mcp.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -86,6 +88,7 @@ function parseApprovalModeValue(value: string): ApprovalMode { } export interface CliArgs { + query: string | undefined; model: string | undefined; sandbox: boolean | string | undefined; sandboxImage: string | undefined; @@ -116,23 +119,98 @@ export interface CliArgs { tavilyApiKey: string | undefined; screenReader: boolean | undefined; vlmSwitchMode: string | undefined; + useSmartEdit: boolean | undefined; + outputFormat: string | undefined; } export async function parseArguments(settings: Settings): Promise { - const yargsInstance = yargs(hideBin(process.argv)) - // Set locale to English for consistent output, especially in tests + const rawArgv = hideBin(process.argv); + const yargsInstance = yargs(rawArgv) .locale('en') .scriptName('qwen') .usage( 'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode', ) - .command('$0', 'Launch Qwen Code', (yargsInstance) => + .option('telemetry', { + type: 'boolean', + description: + 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', + }) + .option('telemetry-target', { + type: 'string', + choices: ['local', 'gcp'], + description: + 'Set the telemetry target (local or gcp). Overrides settings files.', + }) + .option('telemetry-otlp-endpoint', { + type: 'string', + description: + 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', + }) + .option('telemetry-otlp-protocol', { + type: 'string', + choices: ['grpc', 'http'], + description: + 'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.', + }) + .option('telemetry-log-prompts', { + type: 'boolean', + description: + 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', + }) + .option('telemetry-outfile', { + type: 'string', + description: 'Redirect all telemetry output to the specified file.', + }) + .deprecateOption( + 'telemetry', + 'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-target', + 'Use the "telemetry.target" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-otlp-endpoint', + 'Use the "telemetry.otlpEndpoint" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-otlp-protocol', + 'Use the "telemetry.otlpProtocol" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-log-prompts', + 'Use the "telemetry.logPrompts" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'telemetry-outfile', + 'Use the "telemetry.outfile" setting in settings.json instead. This flag will be removed in a future version.', + ) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode?', + default: false, + }) + .option('proxy', { + type: 'string', + description: + 'Proxy for gemini client, like schema://user:password@host:port', + }) + .deprecateOption( + 'proxy', + 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', + ) + .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance: Argv) => yargsInstance + .positional('query', { + description: + 'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.', + }) .option('model', { alias: 'm', type: 'string', description: `Model`, - default: process.env['GEMINI_MODEL'], }) .option('prompt', { alias: 'p', @@ -154,12 +232,6 @@ export async function parseArguments(settings: Settings): Promise { type: 'string', description: 'Sandbox image URI.', }) - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Run in debug mode?', - default: false, - }) .option('all-files', { alias: ['a'], type: 'boolean', @@ -184,37 +256,6 @@ export async function parseArguments(settings: Settings): Promise { description: 'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)', }) - .option('telemetry', { - type: 'boolean', - description: - 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', - }) - .option('telemetry-target', { - type: 'string', - choices: ['local', 'gcp'], - description: - 'Set the telemetry target (local or gcp). Overrides settings files.', - }) - .option('telemetry-otlp-endpoint', { - type: 'string', - description: - 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', - }) - .option('telemetry-otlp-protocol', { - type: 'string', - choices: ['grpc', 'http'], - description: - 'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.', - }) - .option('telemetry-log-prompts', { - type: 'boolean', - description: - 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', - }) - .option('telemetry-outfile', { - type: 'string', - description: 'Redirect all telemetry output to the specified file.', - }) .option('checkpointing', { alias: 'c', type: 'boolean', @@ -229,11 +270,19 @@ export async function parseArguments(settings: Settings): Promise { type: 'array', string: true, description: 'Allowed MCP server names', + coerce: (mcpServerNames: string[]) => + // Handle comma-separated values + mcpServerNames.flatMap((mcpServerName) => + mcpServerName.split(',').map((m) => m.trim()), + ), }) .option('allowed-tools', { type: 'array', string: true, description: 'Tools that are allowed to run without confirmation', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) .option('extensions', { alias: 'e', @@ -241,17 +290,17 @@ export async function parseArguments(settings: Settings): Promise { string: true, description: 'A list of extensions to use. If not provided, all extensions are used.', + coerce: (extensions: string[]) => + // Handle comma-separated values + extensions.flatMap((extension) => + extension.split(',').map((e) => e.trim()), + ), }) .option('list-extensions', { alias: 'l', type: 'boolean', description: 'List all available extensions and exit.', }) - .option('proxy', { - type: 'string', - description: - 'Proxy for qwen client, like schema://user:password@host:port', - }) .option('include-directories', { type: 'array', string: true, @@ -281,7 +330,6 @@ export async function parseArguments(settings: Settings): Promise { .option('screen-reader', { type: 'boolean', description: 'Enable screen reader mode for accessibility.', - default: false, }) .option('vlm-switch-mode', { type: 'string', @@ -290,16 +338,54 @@ export async function parseArguments(settings: Settings): Promise { 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', default: process.env['VLM_SWITCH_MODE'], }) - .check((argv) => { - if (argv.prompt && argv['promptInteractive']) { - throw new Error( - 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', - ); + .option('output-format', { + alias: 'o', + type: 'string', + description: 'The format of the CLI output.', + choices: ['text', 'json'], + }) + .deprecateOption( + 'show-memory-usage', + 'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'sandbox-image', + 'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'checkpointing', + 'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'all-files', + 'Use @ includes in the application instead. This flag will be removed in a future version.', + ) + .deprecateOption( + 'prompt', + 'Use the positional prompt instead. This flag will be removed in a future version.', + ) + // Ensure validation flows through .fail() for clean UX + .fail((msg: string, err: Error | undefined, yargs: Argv) => { + console.error(msg || err?.message || 'Unknown error'); + yargs.showHelp(); + process.exit(1); + }) + .check((argv: { [x: string]: unknown }) => { + // The 'query' positional can be a string (for one arg) or string[] (for multiple). + // This guard safely checks if any positional argument was provided. + 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.yolo && argv['approvalMode']) { - throw new Error( - 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', - ); + 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.'; } return true; }), @@ -307,7 +393,7 @@ export async function parseArguments(settings: Settings): Promise { // Register MCP subcommands .command(mcpCommand); - if (settings?.experimental?.extensionManagement ?? false) { + if (settings?.experimental?.extensionManagement ?? true) { yargsInstance.command(extensionsCommand); } @@ -322,6 +408,8 @@ export async function parseArguments(settings: Settings): Promise { yargsInstance.wrap(yargsInstance.terminalWidth()); const result = await yargsInstance.parse(); + // If yargs handled --help/--version it will have exited; nothing to do here. + // Handle case where MCP subcommands are executed - they should exit the process // and not return to main CLI logic if ( @@ -332,6 +420,26 @@ export async function parseArguments(settings: Settings): Promise { process.exit(0); } + // Normalize query args: handle both quoted "@path file" and unquoted @path file + const queryArg = (result as { query?: string | string[] | undefined }).query; + const q: string | undefined = Array.isArray(queryArg) + ? queryArg.join(' ') + : queryArg; + + // Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands) + if (q && !result['prompt']) { + const hasExplicitInteractive = + result['promptInteractive'] === '' || !!result['promptInteractive']; + if (hasExplicitInteractive) { + result['promptInteractive'] = q; + } else { + result['prompt'] = q; + } + } + + // Keep CliArgs.query as a string for downstream typing + (result as Record)['query'] = q || undefined; + // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument return result as unknown as CliArgs; @@ -347,6 +455,7 @@ export async function loadHierarchicalGeminiMemory( fileService: FileDiscoveryService, settings: Settings, extensionContextFilePaths: string[] = [], + folderTrust: boolean, memoryImportFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, ): Promise<{ memoryContent: string; fileCount: number }> { @@ -372,58 +481,48 @@ export async function loadHierarchicalGeminiMemory( debugMode, fileService, extensionContextFilePaths, + folderTrust, memoryImportFormat, fileFilteringOptions, settings.context?.discoveryMaxDirs, ); } +export function isDebugMode(argv: CliArgs): boolean { + return ( + argv.debug || + [process.env['DEBUG'], process.env['DEBUG_MODE']].some( + (v) => v === 'true' || v === '1', + ) + ); +} + export async function loadCliConfig( settings: Settings, extensions: Extension[], + extensionEnablementManager: ExtensionEnablementManager, sessionId: string, argv: CliArgs, cwd: string = process.cwd(), ): Promise { - const debugMode = - argv.debug || - [process.env['DEBUG'], process.env['DEBUG_MODE']].some( - (v) => v === 'true' || v === '1', - ) || - false; + const debugMode = isDebugMode(argv); + const memoryImportFormat = settings.context?.importFormat || 'tree'; const ideMode = settings.ide?.enabled ?? false; - const folderTrustFeature = - settings.security?.folderTrust?.featureEnabled ?? false; - const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; - const folderTrust = folderTrustFeature && folderTrustSetting; - const trustedFolder = isWorkspaceTrusted(settings); + const folderTrust = settings.security?.folderTrust?.enabled ?? false; + const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true; const allExtensions = annotateActiveExtensions( extensions, - argv.extensions || [], cwd, + extensionEnablementManager, ); const activeExtensions = extensions.filter( (_, i) => allExtensions[i].isActive, ); - // Handle OpenAI API key from command line - if (argv.openaiApiKey) { - process.env['OPENAI_API_KEY'] = argv.openaiApiKey; - } - - // Handle OpenAI base URL from command line - if (argv.openaiBaseUrl) { - process.env['OPENAI_BASE_URL'] = argv.openaiBaseUrl; - } - - // Handle Tavily API key from command line - if (argv.tavilyApiKey) { - process.env['TAVILY_API_KEY'] = argv.tavilyApiKey; - } // 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 @@ -461,6 +560,7 @@ export async function loadCliConfig( fileService, settings, extensionContextFilePaths, + trustedFolder, memoryImportFormat, fileFiltering, ); @@ -474,8 +574,8 @@ export async function loadCliConfig( approvalMode = parseApprovalModeValue(argv.approvalMode); } else if (argv.yolo) { approvalMode = ApprovalMode.YOLO; - } else if (settings.approvalMode) { - approvalMode = parseApprovalModeValue(settings.approvalMode); + } else if (settings.tools?.approvalMode) { + approvalMode = parseApprovalModeValue(settings.tools.approvalMode); } else { approvalMode = ApprovalMode.DEFAULT; } @@ -492,8 +592,27 @@ export async function loadCliConfig( approvalMode = ApprovalMode.DEFAULT; } + let telemetrySettings; + try { + telemetrySettings = await resolveTelemetrySettings({ + argv, + 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; + } + + // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) + const hasQuery = !!argv.query; const interactive = - !!argv.promptInteractive || (process.stdin.isTTY && question.length === 0); + !!argv.promptInteractive || + (process.stdin.isTTY && !hasQuery && !argv.prompt); // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive && !argv.experimentalAcp) { @@ -550,9 +669,15 @@ export async function loadCliConfig( ); } - const sandboxConfig = await loadSandboxConfig(settings, argv); - const cliVersion = await getCliVersion(); + const defaultModel = DEFAULT_QWEN_MODEL; + const resolvedModel: string = + argv.model || + process.env['OPENAI_MODEL'] || + process.env['QWEN_MODEL'] || + settings.model?.name || + defaultModel; + const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -562,7 +687,7 @@ export async function loadCliConfig( argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode; return new Config({ sessionId, - embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, + embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: cwd, includeDirectories, @@ -587,31 +712,9 @@ export async function loadCliConfig( ...settings.ui?.accessibility, screenReader, }, - telemetry: { - enabled: argv.telemetry ?? settings.telemetry?.enabled, - target: (argv.telemetryTarget ?? - settings.telemetry?.target) as TelemetryTarget, - otlpEndpoint: - argv.telemetryOtlpEndpoint ?? - process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? - settings.telemetry?.otlpEndpoint, - otlpProtocol: (['grpc', 'http'] as const).find( - (p) => - p === - (argv.telemetryOtlpProtocol ?? settings.telemetry?.otlpProtocol), - ), - logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts, - outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile, - }, + telemetry: telemetrySettings, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, - // Git-aware file filtering settings - fileFiltering: { - respectGitIgnore: settings.context?.fileFiltering?.respectGitIgnore, - respectGeminiIgnore: settings.context?.fileFiltering?.respectGeminiIgnore, - enableRecursiveFileSearch: - settings.context?.fileFiltering?.enableRecursiveFileSearch, - disableFuzzySearch: settings.context?.fileFiltering?.disableFuzzySearch, - }, + fileFiltering: settings.context?.fileFiltering, checkpointing: argv.checkpointing || settings.general?.checkpointing?.enabled, proxy: @@ -623,50 +726,51 @@ export async function loadCliConfig( cwd, fileDiscoveryService: fileService, bugCommand: settings.advanced?.bugCommand, - model: argv.model || settings.model?.name || DEFAULT_GEMINI_MODEL, + model: resolvedModel, extensionContextFilePaths, - sessionTokenLimit: settings.sessionTokenLimit ?? -1, + sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, maxSessionTurns: settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - enableOpenAILogging: - (typeof argv.openaiLogging === 'undefined' - ? settings.enableOpenAILogging - : argv.openaiLogging) ?? false, - systemPromptMappings: (settings.systemPromptMappings ?? [ - { - baseUrls: [ - 'https://dashscope.aliyuncs.com/compatible-mode/v1/', - 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/', - ], - modelNames: ['qwen3-coder-plus'], - template: - 'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}', - }, - ]) as ConfigParameters['systemPromptMappings'], authType: settings.security?.auth?.selectedType, - contentGenerator: settings.contentGenerator, - cliVersion, + generationConfig: { + ...(settings.model?.generationConfig || {}), + model: resolvedModel, + apiKey: argv.openaiApiKey || process.env['OPENAI_API_KEY'], + baseUrl: argv.openaiBaseUrl || process.env['OPENAI_BASE_URL'], + enableOpenAILogging: + (typeof argv.openaiLogging === 'undefined' + ? settings.model?.enableOpenAILogging + : argv.openaiLogging) ?? false, + }, + cliVersion: await getCliVersion(), tavilyApiKey: argv.tavilyApiKey || - settings.tavilyApiKey || + settings.advanced?.tavilyApiKey || process.env['TAVILY_API_KEY'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, - folderTrustFeature, folderTrust, interactive, trustedFolder, useRipgrep: settings.tools?.useRipgrep, - shouldUseNodePtyShell: settings.tools?.usePty, + shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, - skipLoopDetection: settings.skipLoopDetection ?? false, + skipLoopDetection: settings.model?.skipLoopDetection ?? false, vlmSwitchMode, + truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, + truncateToolOutputLines: settings.tools?.truncateToolOutputLines, + enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, + eventEmitter: appEvents, + useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, + output: { + format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, + }, }); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 068c815d..6f50c301 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -10,37 +10,88 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { EXTENSIONS_CONFIG_FILENAME, + ExtensionStorage, INSTALL_METADATA_FILENAME, annotateActiveExtensions, disableExtension, enableExtension, installExtension, loadExtension, + loadExtensionConfig, loadExtensions, performWorkspaceExtensionMigration, + requestConsentNonInteractive, uninstallExtension, - updateExtension, + type Extension, } from './extension.js'; import { + QWEN_DIR, type GeminiCLIExtension, - type MCPServerConfig, + ExtensionUninstallEvent, + ExtensionDisableEvent, + ExtensionEnableEvent, } from '@qwen-code/qwen-code-core'; import { execSync } from 'node:child_process'; -import { SettingScope, loadSettings } from './settings.js'; -import { type SimpleGit, simpleGit } from 'simple-git'; +import { SettingScope } from './settings.js'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.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(), +}; vi.mock('simple-git', () => ({ - simpleGit: vi.fn(), + simpleGit: vi.fn((path: string) => { + mockGit.path.mockReturnValue(path); + return mockGit; + }), })); vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); + const mockedOs = await importOriginal(); return { - ...os, + ...mockedOs, homedir: vi.fn(), }; }); +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 mockLogExtensionDisable = vi.hoisted(() => vi.fn()); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logExtensionEnable: mockLogExtensionEnable, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + logExtensionDisable: mockLogExtensionDisable, + ExtensionEnableEvent: vi.fn(), + ExtensionInstallEvent: vi.fn(), + ExtensionUninstallEvent: vi.fn(), + ExtensionDisableEvent: vi.fn(), + }; +}); + vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -49,673 +100,1353 @@ vi.mock('child_process', async (importOriginal) => { }; }); -const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions'); +const mockQuestion = vi.hoisted(() => vi.fn()); +const mockClose = vi.hoisted(() => vi.fn()); +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockClose, + })), +})); -describe('loadExtensions', () => { +const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions'); + +describe('extension tests', () => { + let tempHomeDir: string; let tempWorkspaceDir: string; - let tempHomeDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('should include extension path in loaded extension', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - - const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); - fs.mkdirSync(extensionDir, { recursive: true }); - - const config = { - name: 'test-extension', - version: '1.0.0', - }; - fs.writeFileSync( - path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify(config), - ); - - const extensions = loadExtensions(tempWorkspaceDir); - expect(extensions).toHaveLength(1); - expect(extensions[0].path).toBe(extensionDir); - expect(extensions[0].config.name).toBe('test-extension'); - }); - - it('should load context file path when QWEN.md is present', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - createExtension(workspaceExtensionsDir, 'ext1', '1.0.0', true); - createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); - - const extensions = loadExtensions(tempWorkspaceDir); - - expect(extensions).toHaveLength(2); - const ext1 = extensions.find((e) => e.config.name === 'ext1'); - const ext2 = extensions.find((e) => e.config.name === 'ext2'); - expect(ext1?.contextFiles).toEqual([ - path.join(workspaceExtensionsDir, 'ext1', 'QWEN.md'), - ]); - expect(ext2?.contextFiles).toEqual([]); - }); - - it('should load context file path from the extension config', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - createExtension( - workspaceExtensionsDir, - 'ext1', - '1.0.0', - false, - 'my-context-file.md', - ); - - const extensions = loadExtensions(tempWorkspaceDir); - - expect(extensions).toHaveLength(1); - const ext1 = extensions.find((e) => e.config.name === 'ext1'); - expect(ext1?.contextFiles).toEqual([ - path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'), - ]); - }); - - it('should filter out disabled extensions', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - - createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); - - const settingsDir = path.join(tempWorkspaceDir, '.qwen'); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ extensions: { disabled: ['ext1'] } }), - ); - - const extensions = loadExtensions(tempWorkspaceDir); - const activeExtensions = annotateActiveExtensions( - extensions, - [], - tempWorkspaceDir, - ).filter((e) => e.isActive); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext2'); - }); - - it('should hydrate variables', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - - createExtension( - workspaceExtensionsDir, - 'test-extension', - '1.0.0', - false, - undefined, - { - 'test-server': { - cwd: '${extensionPath}${/}server', - }, - }, - ); - - const extensions = loadExtensions(tempWorkspaceDir); - expect(extensions).toHaveLength(1); - const loadedConfig = extensions[0].config; - const expectedCwd = path.join( - workspaceExtensionsDir, - 'test-extension', - 'server', - ); - expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); - }); -}); - -describe('annotateActiveExtensions', () => { - const extensions = [ - { - path: '/path/to/ext1', - config: { name: 'ext1', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext2', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext3', - config: { name: 'ext3', version: '1.0.0' }, - contextFiles: [], - }, - ]; - - it('should mark all extensions as active if no enabled extensions are provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - [], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => e.isActive)).toBe(true); - }); - - it('should mark only the enabled extensions as active', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['ext1', 'ext3'], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe( - false, - ); - expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe( - true, - ); - }); - - it('should mark all extensions as inactive when "none" is provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['none'], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => !e.isActive)).toBe(true); - }); - - it('should handle case-insensitivity', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['EXT1'], - '/path/to/workspace', - ); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - }); - - it('should log an error for unknown extensions', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); - expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); - consoleSpy.mockRestore(); - }); -}); - -describe('installExtension', () => { - let tempHomeDir: string; let userExtensionsDir: string; beforeEach(() => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'qwen-code-test-home-'), ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'qwen-code-test-workspace-'), + ); + userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + mockQuestion.mockImplementation((_query, callback) => callback('y')); vi.mocked(execSync).mockClear(); + Object.values(mockGit).forEach((fn) => fn.mockReset()); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + mockQuestion.mockClear(); + mockClose.mockClear(); }); - it('should install an extension from a local path', async () => { - const sourceExtDir = createExtension( - tempHomeDir, - 'my-local-extension', - '1.0.0', - ); - const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + describe('loadExtensions', () => { + it('should include extension path in loaded extension', () => { + const extensionDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); - await installExtension({ source: sourceExtDir, type: 'local' }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); - 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( - tempHomeDir, - 'my-local-extension', - '1.0.0', - ); - await installExtension({ source: sourceExtDir, type: 'local' }); - await expect( - installExtension({ 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 qwen-extension.json is missing', async () => { - const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); - fs.mkdirSync(sourceExtDir, { recursive: true }); - - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).rejects.toThrow( - `Invalid extension at ${sourceExtDir}. Please make sure it has a valid qwen-extension.json file.`, - ); - - const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); - expect(fs.existsSync(targetExtDir)).toBe(false); - }); - - it('should install an extension from a git URL', async () => { - const gitUrl = 'https://github.com/google/qwen-extensions.git'; - const extensionName = 'qwen-extensions'; - const targetExtDir = path.join(userExtensionsDir, extensionName); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - const clone = vi.fn().mockImplementation(async (_, destination) => { - fs.mkdirSync(destination, { recursive: true }); - fs.writeFileSync( - path.join(destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.0.0' }), + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), ); + expect(extensions).toHaveLength(1); + expect(extensions[0].path).toBe(extensionDir); + expect(extensions[0].config.name).toBe('test-extension'); }); - const mockedSimpleGit = simpleGit as vi.MockedFunction; - mockedSimpleGit.mockReturnValue({ clone } as unknown as SimpleGit); + it('should load context file path when QWEN.md is present', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: true, + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '2.0.0', + }); - await installExtension({ source: gitUrl, type: 'git' }); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); - 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', + expect(extensions).toHaveLength(2); + const ext1 = extensions.find((e) => e.config.name === 'ext1'); + const ext2 = extensions.find((e) => e.config.name === 'ext2'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'QWEN.md'), + ]); + expect(ext2?.contextFiles).toEqual([]); }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); -}); -describe('uninstallExtension', () => { - let tempHomeDir: string; - let userExtensionsDir: string; + it('should load context file path from the extension config', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: false, + contextFileName: 'my-context-file.md', + }); - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - fs.mkdirSync(userExtensionsDir, { recursive: true }); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); - vi.mocked(execSync).mockClear(); - }); + expect(extensions).toHaveLength(1); + const ext1 = extensions.find((e) => e.config.name === 'ext1'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), + ]); + }); - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); + it('should filter out disabled extensions', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'disabled-extension', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'enabled-extension', + version: '2.0.0', + }); + disableExtension( + 'disabled-extension', + SettingScope.User, + tempWorkspaceDir, + ); + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const extensions = loadExtensions(manager); + const activeExtensions = annotateActiveExtensions( + extensions, + tempWorkspaceDir, + manager, + ).filter((e) => e.isActive); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('enabled-extension'); + }); - it('should uninstall an extension by name', async () => { - const sourceExtDir = createExtension( - userExtensionsDir, - 'my-local-extension', - '1.0.0', - ); + it('should hydrate variables', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + addContextFile: false, + contextFileName: undefined, + mcpServers: { + 'test-server': { + cwd: '${extensionPath}${/}server', + }, + }, + }); - await uninstallExtension('my-local-extension'); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + expect(extensions).toHaveLength(1); + const loadedConfig = extensions[0].config; + const expectedCwd = path.join( + userExtensionsDir, + 'test-extension', + 'server', + ); + expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); + }); - expect(fs.existsSync(sourceExtDir)).toBe(false); - }); + it('should load a linked extension correctly', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'my-linked-extension', + version: '1.0.0', + contextFileName: 'context.md', + }); + fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - it('should uninstall an extension by name and retain existing extensions', async () => { - const sourceExtDir = createExtension( - userExtensionsDir, - 'my-local-extension', - '1.0.0', - ); - const otherExtDir = createExtension( - userExtensionsDir, - 'other-extension', - '1.0.0', - ); + const extensionName = await installExtension( + { + source: sourceExtDir, + type: 'link', + }, + async (_) => true, + ); - await uninstallExtension('my-local-extension'); + expect(extensionName).toEqual('my-linked-extension'); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + expect(extensions).toHaveLength(1); - expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(loadExtensions(tempHomeDir)).toHaveLength(1); - expect(fs.existsSync(otherExtDir)).toBe(true); - }); + const linkedExt = extensions[0]; + expect(linkedExt.config.name).toBe('my-linked-extension'); - it('should throw an error if the extension does not exist', async () => { - await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Extension "nonexistent-extension" not found.', - ); - }); -}); + expect(linkedExt.path).toBe(sourceExtDir); + expect(linkedExt.installMetadata).toEqual({ + source: sourceExtDir, + type: 'link', + }); + expect(linkedExt.contextFiles).toEqual([ + path.join(sourceExtDir, 'context.md'), + ]); + }); -describe('performWorkspaceExtensionMigration', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; + it('should resolve environment variables in extension configuration', () => { + process.env.TEST_API_KEY = 'test-api-key-123'; + process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - }); + try { + const userExtensionsDir = path.join( + tempHomeDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(userExtensionsDir, { recursive: true }); - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); + const extDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extDir); - it('should install the extensions in the user directory', async () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - const ext2Path = createExtension(workspaceExtensionsDir, 'ext2', '1.0.0'); - const extensionsToMigrate = [ - loadExtension(ext1Path)!, - loadExtension(ext2Path)!, - ]; - const failed = - await performWorkspaceExtensionMigration(extensionsToMigrate); + // 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)); - expect(failed).toEqual([]); + const extensions = loadExtensions( + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ); - const userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions'); - const userExt1Path = path.join(userExtensionsDir, 'ext1'); - const extensions = loadExtensions(tempWorkspaceDir); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + expect(extension.config.name).toBe('test-extension'); + expect(extension.config.mcpServers).toBeDefined(); - expect(extensions).toHaveLength(2); - const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: ext1Path, - type: 'local', + const serverConfig = extension.config.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 handle missing environment variables gracefully', () => { + 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 = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + const serverConfig = extension.config.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 skip extensions with invalid JSON and log a warning', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .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); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed + + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should skip extensions with missing name and log a warning', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .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); + const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); + fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); + + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + + expect(extensions).toHaveLength(1); + expect(extensions[0].config.name).toBe('good-ext'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, + ), + ); + + consoleSpy.mockRestore(); + }); + + it('should filter trust out of mcp servers', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + trust: true, + }, + }, + }); + + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + expect(extensions).toHaveLength(1); + const loadedConfig = extensions[0].config; + expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined(); + }); + + it('should throw an error for invalid extension names', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const badExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'bad_name', + version: '1.0.0', + }); + + const extension = loadExtension({ + extensionDir: badExtDir, + workspaceDir: tempWorkspaceDir, + }); + + expect(extension).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name: "bad_name"'), + ); + consoleSpy.mockRestore(); }); }); - it('should return the names of failed installations', async () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - - const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); - - const extensions = [ - loadExtension(ext1Path)!, + describe('annotateActiveExtensions', () => { + const extensions: Extension[] = [ { - path: '/ext/path/1', + path: '/path/to/ext1', + config: { name: 'ext1', version: '1.0.0' }, + contextFiles: [], + }, + { + path: '/path/to/ext2', config: { name: 'ext2', version: '1.0.0' }, contextFiles: [], }, + { + path: '/path/to/ext3', + config: { name: 'ext3', version: '1.0.0' }, + contextFiles: [], + }, ]; - const failed = await performWorkspaceExtensionMigration(extensions); - expect(failed).toEqual(['ext2']); - }); -}); + it('should mark all extensions as active if no enabled extensions are provided', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + '/path/to/workspace', + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.every((e) => e.isActive)).toBe(true); + }); -function createExtension( - extensionsDir: string, - name: string, - version: string, - addContextFile = false, - contextFileName?: string, - mcpServers?: Record, -): 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 }), - ); - - if (addContextFile) { - fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context'); - } - - if (contextFileName) { - fs.writeFileSync(path.join(extDir, contextFileName), 'context'); - } - return extDir; -} - -describe('updateExtension', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - - vi.mocked(execSync).mockClear(); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); - - it('should update a git-installed extension', async () => { - // 1. "Install" an extension - const gitUrl = 'https://github.com/google/qwen-extensions.git'; - const extensionName = 'qwen-extensions'; - const targetExtDir = path.join(userExtensionsDir, extensionName); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - // Create the "installed" extension directory and files - fs.mkdirSync(targetExtDir, { recursive: true }); - fs.writeFileSync( - path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.0.0' }), - ); - fs.writeFileSync( - metadataPath, - JSON.stringify({ source: gitUrl, type: 'git' }), - ); - - // 2. Mock the git clone for the update - const clone = vi.fn().mockImplementation(async (_, destination) => { - fs.mkdirSync(destination, { recursive: true }); - // This is the "updated" version - fs.writeFileSync( - path.join(destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.1.0' }), + it('should mark only the enabled extensions as active', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + '/path/to/workspace', + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ['ext1', 'ext3'], + ), + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( + true, + ); + expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe( + false, + ); + expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe( + true, ); }); - const mockedSimpleGit = simpleGit as vi.MockedFunction; - mockedSimpleGit.mockReturnValue({ - clone, - } as unknown as SimpleGit); - - // 3. Call updateExtension - const updateInfo = await updateExtension(extensionName); - - // 4. Assertions - expect(updateInfo).toEqual({ - originalVersion: '1.0.0', - updatedVersion: '1.1.0', + it('should mark all extensions as inactive when "none" is provided', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + '/path/to/workspace', + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ['none'], + ), + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.every((e) => !e.isActive)).toBe(true); }); - // Check that the config file reflects the new version - const updatedConfig = JSON.parse( - fs.readFileSync( - path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), - 'utf-8', - ), - ); - expect(updatedConfig.version).toBe('1.1.0'); + it('should handle case-insensitivity', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + '/path/to/workspace', + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ['EXT1'], + ), + ); + expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( + true, + ); + }); + + it('should log an error for unknown extensions', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + annotateActiveExtensions( + extensions, + '/path/to/workspace', + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ['ext4'], + ), + ); + expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); + consoleSpy.mockRestore(); + }); + + describe('autoUpdate', () => { + it('should be false if autoUpdate is not set in install metadata', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + tempHomeDir, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ); + expect( + activeExtensions.every( + (e) => e.installMetadata?.autoUpdate === false, + ), + ).toBe(false); + }); + + it('should be true if autoUpdate is true in install metadata', () => { + const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({ + ...e, + installMetadata: { + ...e.installMetadata!, + autoUpdate: true, + }, + })); + const activeExtensions = annotateActiveExtensions( + extensionsWithAutoUpdate, + tempHomeDir, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ); + expect( + activeExtensions.every((e) => e.installMetadata?.autoUpdate === true), + ).toBe(true); + }); + + it('should respect the per-extension settings from install metadata', () => { + const extensionsWithAutoUpdate: Extension[] = [ + { + path: '/path/to/ext1', + config: { name: 'ext1', version: '1.0.0' }, + contextFiles: [], + installMetadata: { + source: 'test', + type: 'local', + autoUpdate: true, + }, + }, + { + path: '/path/to/ext2', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + installMetadata: { + source: 'test', + type: 'local', + autoUpdate: false, + }, + }, + { + path: '/path/to/ext3', + config: { name: 'ext3', version: '1.0.0' }, + contextFiles: [], + }, + ]; + const activeExtensions = annotateActiveExtensions( + extensionsWithAutoUpdate, + tempHomeDir, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ); + expect( + activeExtensions.find((e) => e.name === 'ext1')?.installMetadata + ?.autoUpdate, + ).toBe(true); + expect( + activeExtensions.find((e) => e.name === 'ext2')?.installMetadata + ?.autoUpdate, + ).toBe(false); + expect( + activeExtensions.find((e) => e.name === 'ext3')?.installMetadata + ?.autoUpdate, + ).toBe(undefined); + }); + }); + }); + + describe('installExtension', () => { + it('should install an extension from a local path', async () => { + const sourceExtDir = 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 installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => 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', + }); + 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 installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ); + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ), + ).rejects.toThrow( + 'Extension "my-local-extension" is already installed. Please uninstall it first.', + ); + }); + + it('should throw an error and cleanup if qwen-extension.json is missing', async () => { + const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); + fs.mkdirSync(sourceExtDir, { recursive: true }); + const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); + + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ), + ).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 qwen-extension.json', async () => { + const sourceExtDir = 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( + installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ), + ).rejects.toThrow( + new RegExp( + `^Failed to load extension config from ${configPath.replace( + /\\/g, + '\\\\', + )}`, + ), + ); + }); + + it('should throw an error for missing name in qwen-extension.json', async () => { + const sourceExtDir = 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( + installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ), + ).rejects.toThrow( + `Invalid configuration in ${configPath}: missing "name"`, + ); + }); + + it('should install an extension from a git URL', async () => { + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'qwen-extensions'; + 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' }]); + + await installExtension( + { source: gitUrl, type: 'git' }, + async (_) => 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: gitUrl, + type: 'git', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should install a linked extension', async () => { + const sourceExtDir = 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 installExtension( + { source: sourceExtDir, type: 'link' }, + async (_) => true, + ); + + 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 log to clearcut on successful install', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await installExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ); + + expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); + }); + + it('should show users information on their mcp server when installing', async () => { + const consoleInfoSpy = vi.spyOn(console, 'info'); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + description: 'a local mcp server', + }, + 'test-server-2': { + description: 'a remote mcp server', + httpUrl: 'https://google.com', + }, + }, + }); + + mockQuestion.mockImplementation((_query, callback) => callback('y')); + + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + requestConsentNonInteractive, + ), + ).resolves.toBe('my-local-extension'); + + expect(consoleInfoSpy).toHaveBeenCalledWith( + `Installing extension "my-local-extension". +**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.** +This extension will run the following MCP servers: + * test-server (local): node server.js + * test-server-2 (remote): https://google.com`, + ); + }); + + 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'], + }, + }, + }); + + mockQuestion.mockImplementation((_query, callback) => callback('y')); + + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + requestConsentNonInteractive, + ), + ).resolves.toBe('my-local-extension'); + + expect(mockQuestion).toHaveBeenCalledWith( + expect.stringContaining('Do you want to continue? [Y/n]: '), + expect.any(Function), + ); + }); + + 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'], + }, + }, + }); + + mockQuestion.mockImplementation((_query, callback) => callback('n')); + + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + requestConsentNonInteractive, + ), + ).rejects.toThrow('Installation cancelled for "my-local-extension".'); + + expect(mockQuestion).toHaveBeenCalledWith( + expect.stringContaining('Do you want to continue? [Y/n]: '), + expect.any(Function), + ); + }); + + it('should save the autoUpdate flag to the install metadata', async () => { + const sourceExtDir = 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 installExtension( + { + source: sourceExtDir, + type: 'local', + autoUpdate: true, + }, + async (_) => 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'], + }, + }, + }); + + const mockRequestConsent = vi.fn(); + + await expect( + installExtension( + { source: sourceExtDir, type: 'local' }, + mockRequestConsent, + process.cwd(), + // Provide its own existing config as the previous config. + await loadExtensionConfig({ + extensionDir: sourceExtDir, + workspaceDir: process.cwd(), + }), + ), + ).resolves.toBe('my-local-extension'); + + expect(mockRequestConsent).not.toHaveBeenCalled(); + }); + + it('should throw an error for invalid extension names', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'bad_name', + version: '1.0.0', + }); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow('Invalid extension name: "bad_name"'); + }); + }); + + describe('uninstallExtension', () => { + it('should uninstall an extension by name', async () => { + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await uninstallExtension('my-local-extension'); + + 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 uninstallExtension('my-local-extension'); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect( + loadExtensions( + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ), + ).toHaveLength(1); + expect(fs.existsSync(otherExtDir)).toBe(true); + }); + + it('should throw an error if the extension does not exist', async () => { + await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( + 'Extension not found.', + ); + }); + + it('should log uninstall event', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await uninstallExtension('my-local-extension'); + + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'my-local-extension', + 'success', + ); + }); + + 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 uninstallExtension(gitUrl); + + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + '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 expect( + uninstallExtension('https://github.com/google/no-metadata-extension'), + ).rejects.toThrow('Extension not found.'); + }); + }); + + describe('performWorkspaceExtensionMigration', () => { + let workspaceExtensionsDir: string; + + beforeEach(() => { + workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(workspaceExtensionsDir, { recursive: true, force: true }); + }); + + describe('folder trust', () => { + it('refuses to install extensions from untrusted folders', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const failed = await performWorkspaceExtensionMigration([ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + ]); + + expect(failed).toEqual(['ext1']); + }); + + it('does not copy extensions to the user dir', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration( + [ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + ], + async (_) => true, + ); + + const userExtensionsDir = path.join( + tempHomeDir, + QWEN_DIR, + 'extensions', + ); + expect(fs.readdirSync(userExtensionsDir).length).toBe(0); + }); + + it('does not load any extensions in the workspace config', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration( + [ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + ], + async (_) => true, + ); + const extensions = loadExtensions( + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ), + ); + + expect(extensions).toEqual([]); + }); + }); + + it('should install the extensions in the user directory', async () => { + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + const ext2Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + const extensionsToMigrate: Extension[] = [ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + loadExtension({ + extensionDir: ext2Path, + workspaceDir: tempWorkspaceDir, + })!, + ]; + const failed = await performWorkspaceExtensionMigration( + extensionsToMigrate, + async (_) => true, + ); + + expect(failed).toEqual([]); + + const userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions'); + const userExt1Path = path.join(userExtensionsDir, 'ext1'); + const extensions = loadExtensions( + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + ); + + expect(extensions).toHaveLength(2); + const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: ext1Path, + type: 'local', + }); + }); + + it('should return the names of failed installations', async () => { + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + const extensions: Extension[] = [ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + { + path: '/ext/path/1', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + }, + ]; + + const failed = await performWorkspaceExtensionMigration( + extensions, + async (_) => true, + ); + expect(failed).toEqual(['ext2']); + }); + }); + + describe('disableExtension', () => { + it('should disable an extension at the user scope', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + disableExtension('my-extension', SettingScope.User); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should disable an extension at the workspace scope', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + disableExtension( + 'my-extension', + SettingScope.Workspace, + tempWorkspaceDir, + ); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempHomeDir, + }), + ).toBe(true); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should handle disabling the same extension twice', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + disableExtension('my-extension', SettingScope.User); + disableExtension('my-extension', SettingScope.User); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should throw an error if you request system scope', () => { + expect(() => + disableExtension('my-extension', SettingScope.System), + ).toThrow('System and SystemDefaults scopes are not supported.'); + }); + + it('should log a disable event', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + disableExtension('ext1', SettingScope.Workspace); + + expect(mockLogExtensionDisable).toHaveBeenCalled(); + expect(ExtensionDisableEvent).toHaveBeenCalledWith( + 'ext1', + SettingScope.Workspace, + ); + }); + }); + + describe('enableExtension', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + const getActiveExtensions = (): GeminiCLIExtension[] => { + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const extensions = loadExtensions(manager); + const activeExtensions = annotateActiveExtensions( + extensions, + tempWorkspaceDir, + manager, + ); + return activeExtensions.filter((e) => e.isActive); + }; + + it('should enable an extension at the user scope', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.User); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); + + 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', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.Workspace); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); + + enableExtension('ext1', SettingScope.Workspace); + activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext1'); + }); + + it('should log an enable event', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.Workspace); + enableExtension('ext1', SettingScope.Workspace); + + expect(mockLogExtensionEnable).toHaveBeenCalled(); + expect(ExtensionEnableEvent).toHaveBeenCalledWith( + 'ext1', + SettingScope.Workspace, + ); + }); }); }); -describe('disableExtension', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); - - it('should disable an extension at the user scope', () => { - disableExtension('my-extension', SettingScope.User); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.User).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should disable an extension at the workspace scope', () => { - disableExtension('my-extension', SettingScope.Workspace); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.Workspace).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should handle disabling the same extension twice', () => { - disableExtension('my-extension', SettingScope.User); - disableExtension('my-extension', SettingScope.User); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.User).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should throw an error if you request system scope', () => { - expect(() => disableExtension('my-extension', SettingScope.System)).toThrow( - 'System and SystemDefaults scopes are not supported.', - ); - }); -}); - -describe('enableExtension', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions'); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - - const getActiveExtensions = (): GeminiCLIExtension[] => { - const extensions = loadExtensions(tempWorkspaceDir); - const activeExtensions = annotateActiveExtensions( - extensions, - [], - tempWorkspaceDir, - ); - return activeExtensions.filter((e) => e.isActive); - }; - - it('should enable an extension at the user scope', () => { - createExtension(userExtensionsDir, 'ext1', '1.0.0'); - disableExtension('ext1', SettingScope.User); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - 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', () => { - createExtension(userExtensionsDir, 'ext1', '1.0.0'); - disableExtension('ext1', SettingScope.Workspace); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - enableExtension('ext1', [SettingScope.Workspace]); - activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext1'); - }); -}); +function isEnabled(options: { + name: string; + configDir: string; + enabledForPath: string; +}) { + const manager = new ExtensionEnablementManager(options.configDir); + return manager.isEnabled(options.name, options.enabledForPath); +} diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index db674861..fc724a54 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,19 +7,42 @@ import type { MCPServerConfig, GeminiCLIExtension, + ExtensionInstallMetadata, +} from '@qwen-code/qwen-code-core'; +import { + QWEN_DIR, + Storage, + Config, + ExtensionInstallEvent, + ExtensionUninstallEvent, + ExtensionDisableEvent, + ExtensionEnableEvent, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, + logExtensionDisable, } from '@qwen-code/qwen-code-core'; -import { Storage } from '@qwen-code/qwen-code-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { simpleGit } from 'simple-git'; import { SettingScope, loadSettings } from '../config/settings.js'; import { getErrorMessage } from '../utils/errors.js'; import { recursivelyHydrateStrings } from './extensions/variables.js'; +import { isWorkspaceTrusted } from './trustedFolders.js'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { randomUUID } from 'node:crypto'; +import { + cloneFromGit, + downloadFromGitHubRelease, +} from './extensions/github.js'; +import type { LoadExtensionContext } from './extensions/variableSchema.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import chalk from 'chalk'; +import type { ConfirmationRequest } from '../ui/types.js'; + +export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions'); -export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json'; -export const EXTENSIONS_CONFIG_FILENAME_OLD = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.qwen-extension-install.json'; export interface Extension { @@ -37,12 +60,8 @@ export interface ExtensionConfig { excludeTools?: string[]; } -export interface ExtensionInstallMetadata { - source: string; - type: 'git' | 'local'; -} - export interface ExtensionUpdateInfo { + name: string; originalVersion: string; updatedVersion: string; } @@ -76,10 +95,14 @@ export class ExtensionStorage { } export function getWorkspaceExtensions(workspaceDir: string): Extension[] { + // If the workspace dir is the user extensions dir, there are no workspace extensions. + if (path.resolve(workspaceDir) === path.resolve(os.homedir())) { + return []; + } return loadExtensionsFromDir(workspaceDir); } -async function copyExtension( +export async function copyExtension( source: string, destination: string, ): Promise { @@ -88,6 +111,7 @@ async function copyExtension( export async function performWorkspaceExtensionMigration( extensions: Extension[], + requestConsent: (consent: string) => Promise, ): Promise { const failedInstallNames: string[] = []; @@ -97,7 +121,7 @@ export async function performWorkspaceExtensionMigration( source: extension.path, type: 'local', }; - await installExtension(installMetadata); + await installExtension(installMetadata, requestConsent); } catch (_) { failedInstallNames.push(extension.config.name); } @@ -105,20 +129,41 @@ export async function performWorkspaceExtensionMigration( return failedInstallNames; } -export function loadExtensions(workspaceDir: string): Extension[] { +function getTelemetryConfig(cwd: string) { + const settings = loadSettings(cwd); + const config = new Config({ + telemetry: settings.merged.telemetry, + interactive: false, + sessionId: randomUUID(), + targetDir: cwd, + cwd, + model: '', + debugMode: false, + }); + return config; +} + +export function loadExtensions( + extensionEnablementManager: ExtensionEnablementManager, + workspaceDir: string = process.cwd(), +): Extension[] { const settings = loadSettings(workspaceDir).merged; - const disabledExtensions = settings.extensions?.disabled ?? []; const allExtensions = [...loadUserExtensions()]; - if (!settings.experimental?.extensionManagement) { + if ( + (isWorkspaceTrusted(settings) ?? true) && + // Default management setting to true + !(settings.experimental?.extensionManagement ?? true) + ) { allExtensions.push(...getWorkspaceExtensions(workspaceDir)); } const uniqueExtensions = new Map(); + for (const extension of allExtensions) { if ( !uniqueExtensions.has(extension.config.name) && - !disabledExtensions.includes(extension.config.name) + extensionEnablementManager.isEnabled(extension.config.name, workspaceDir) ) { uniqueExtensions.set(extension.config.name, extension); } @@ -151,7 +196,7 @@ export function loadExtensionsFromDir(dir: string): Extension[] { for (const subdir of fs.readdirSync(extensionsDir)) { const extensionDir = path.join(extensionsDir, subdir); - const extension = loadExtension(extensionDir); + const extension = loadExtension({ extensionDir, workspaceDir: dir }); if (extension != null) { extensions.push(extension); } @@ -159,56 +204,51 @@ export function loadExtensionsFromDir(dir: string): Extension[] { return extensions; } -export function loadExtension(extensionDir: string): Extension | null { +export function loadExtension(context: LoadExtensionContext): Extension | null { + const { extensionDir, workspaceDir } = context; if (!fs.statSync(extensionDir).isDirectory()) { - console.error( - `Warning: unexpected file ${extensionDir} in extensions directory.`, - ); return null; } - let configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); - if (!fs.existsSync(configFilePath)) { - const oldConfigFilePath = path.join( - extensionDir, - EXTENSIONS_CONFIG_FILENAME_OLD, - ); - if (!fs.existsSync(oldConfigFilePath)) { - console.error( - `Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`, - ); - return null; - } - configFilePath = oldConfigFilePath; + const installMetadata = loadInstallMetadata(extensionDir); + let effectiveExtensionPath = extensionDir; + + if (installMetadata?.type === 'link') { + effectiveExtensionPath = installMetadata.source; } try { - const configContent = fs.readFileSync(configFilePath, 'utf-8'); - const config = recursivelyHydrateStrings(JSON.parse(configContent), { - extensionPath: extensionDir, - '/': path.sep, - pathSeparator: path.sep, - }) as unknown as ExtensionConfig; - if (!config.name || !config.version) { - console.error( - `Invalid extension config in ${configFilePath}: missing name or version.`, + let config = loadExtensionConfig({ + extensionDir: effectiveExtensionPath, + workspaceDir, + }); + + config = resolveEnvVarsInObject(config); + + if (config.mcpServers) { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), ); - return null; } const contextFiles = getContextFileNames(config) - .map((contextFileName) => path.join(extensionDir, contextFileName)) + .map((contextFileName) => + path.join(effectiveExtensionPath, contextFileName), + ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); return { - path: extensionDir, + path: effectiveExtensionPath, config, contextFiles, - installMetadata: loadInstallMetadata(extensionDir), + installMetadata, }; } catch (e) { console.error( - `Warning: error parsing extension config in ${configFilePath}: ${getErrorMessage( + `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( e, )}`, ); @@ -216,7 +256,39 @@ export function loadExtension(extensionDir: string): Extension | null { } } -function loadInstallMetadata( +export function loadExtensionByName( + name: string, + workspaceDir: string = process.cwd(), +): Extension | null { + const userExtensionsDir = ExtensionStorage.getUserExtensionsDir(); + if (!fs.existsSync(userExtensionsDir)) { + return null; + } + + for (const subdir of fs.readdirSync(userExtensionsDir)) { + const extensionDir = path.join(userExtensionsDir, subdir); + if (!fs.statSync(extensionDir).isDirectory()) { + continue; + } + const extension = loadExtension({ extensionDir, workspaceDir }); + if ( + extension && + extension.config.name.toLowerCase() === name.toLowerCase() + ) { + return extension; + } + } + + return null; +} + +function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { trust, ...rest } = original; + return Object.freeze(rest); +} + +export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); @@ -240,183 +312,416 @@ function getContextFileNames(config: ExtensionConfig): string[] { /** * Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active. - * If enabledExtensionNames is empty, an extension is active unless it is in list of disabled extensions in settings. + * If enabledExtensionNames is empty, an extension is active unless it is disabled. * @param extensions The base list of extensions. * @param enabledExtensionNames The names of explicitly enabled extensions. * @param workspaceDir The current workspace directory. */ export function annotateActiveExtensions( extensions: Extension[], - enabledExtensionNames: string[], workspaceDir: string, + manager: ExtensionEnablementManager, ): GeminiCLIExtension[] { - const settings = loadSettings(workspaceDir).merged; - const disabledExtensions = settings.extensions?.disabled ?? []; - - const annotatedExtensions: GeminiCLIExtension[] = []; - - if (enabledExtensionNames.length === 0) { - return extensions.map((extension) => ({ - name: extension.config.name, - version: extension.config.version, - isActive: !disabledExtensions.includes(extension.config.name), - path: extension.path, - })); - } - - const lowerCaseEnabledExtensions = new Set( - enabledExtensionNames.map((e) => e.trim().toLowerCase()), - ); - - if ( - lowerCaseEnabledExtensions.size === 1 && - lowerCaseEnabledExtensions.has('none') - ) { - return extensions.map((extension) => ({ - name: extension.config.name, - version: extension.config.version, - isActive: false, - path: extension.path, - })); - } - - const notFoundNames = new Set(lowerCaseEnabledExtensions); - - for (const extension of extensions) { - const lowerCaseName = extension.config.name.toLowerCase(); - const isActive = lowerCaseEnabledExtensions.has(lowerCaseName); - - if (isActive) { - notFoundNames.delete(lowerCaseName); - } - - annotatedExtensions.push({ - name: extension.config.name, - version: extension.config.version, - isActive, - path: extension.path, - }); - } - - for (const requestedName of notFoundNames) { - console.error(`Extension not found: ${requestedName}`); - } - - return annotatedExtensions; + manager.validateExtensionOverrides(extensions); + return extensions.map((extension) => ({ + name: extension.config.name, + version: extension.config.version, + isActive: manager.isEnabled(extension.config.name, workspaceDir), + path: extension.path, + installMetadata: extension.installMetadata, + })); } /** - * Clones a Git repository to a specified local path. - * @param gitUrl The Git URL to clone. - * @param destination The destination path to clone the repository to. + * 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. */ -async function cloneFromGit( - gitUrl: string, - destination: string, -): Promise { - try { - // TODO(chrstnb): Download the archive instead to avoid unnecessary .git info. - await simpleGit().clone(gitUrl, destination, ['--depth', '1']); - } catch (error) { - throw new Error(`Failed to clone Git repository from ${gitUrl}`, { - cause: error, +export async function requestConsentNonInteractive( + consentDescription: string, +): Promise { + console.info(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 setExtensionUpdateConfirmationRequest 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 await 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 + * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter. + */ +async function promptForConsentNonInteractive( + prompt: string, +): 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(); + resolve(['y', ''].includes(answer.trim().toLowerCase())); }); - } + }); +} + +/** + * 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 setExtensionUpdateConfirmationRequest 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 await new Promise((resolve) => { + addExtensionUpdateConfirmationRequest({ + prompt, + onConfirm: (resolvedConfirmed) => { + resolve(resolvedConfirmed); + }, + }); + }); } export async function installExtension( installMetadata: ExtensionInstallMetadata, + requestConsent: (consent: string) => Promise, cwd: string = process.cwd(), + previousExtensionConfig?: ExtensionConfig, ): Promise { - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - await fs.promises.mkdir(extensionsDir, { recursive: true }); + const telemetryConfig = getTelemetryConfig(cwd); + let newExtensionConfig: ExtensionConfig | null = null; + let localSourcePath: string | undefined; - // Convert relative paths to absolute paths for the metadata file. - if ( - installMetadata.type === 'local' && - !path.isAbsolute(installMetadata.source) - ) { - installMetadata.source = path.resolve(cwd, installMetadata.source); - } - - let localSourcePath: string; - let tempDir: string | undefined; - if (installMetadata.type === 'git') { - tempDir = await ExtensionStorage.createTmpDir(); - await cloneFromGit(installMetadata.source, tempDir); - localSourcePath = tempDir; - } else { - localSourcePath = installMetadata.source; - } - let newExtensionName: string | undefined; try { - const newExtension = loadExtension(localSourcePath); - if (!newExtension) { + const settings = loadSettings(cwd).merged; + if (!isWorkspaceTrusted(settings)) { throw new Error( - `Invalid extension at ${installMetadata.source}. Please make sure it has a valid qwen-extension.json file.`, + `Could not install extension from untrusted folder at ${installMetadata.source}`, ); } - // ~/.qwen/extensions/{ExtensionConfig.name}. - newExtensionName = newExtension.config.name; - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + await fs.promises.mkdir(extensionsDir, { recursive: true }); - const installedExtensions = loadUserExtensions(); if ( - installedExtensions.some( - (installed) => installed.config.name === newExtensionName, - ) + !path.isAbsolute(installMetadata.source) && + (installMetadata.type === 'local' || installMetadata.type === 'link') ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, - ); + installMetadata.source = path.resolve(cwd, installMetadata.source); } - await copyExtension(localSourcePath, destinationPath); + let tempDir: string | undefined; - const metadataString = JSON.stringify(installMetadata, null, 2); - const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME); - await fs.promises.writeFile(metadataPath, metadataString); - } finally { - if (tempDir) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + if ( + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { + tempDir = await ExtensionStorage.createTmpDir(); + try { + const result = await downloadFromGitHubRelease( + installMetadata, + tempDir, + ); + installMetadata.type = result.type; + installMetadata.releaseTag = result.tagName; + } catch (_error) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } + localSourcePath = tempDir; + } else if ( + installMetadata.type === 'local' || + installMetadata.type === 'link' + ) { + localSourcePath = installMetadata.source; + } else { + throw new Error(`Unsupported install type: ${installMetadata.type}`); + } + + try { + newExtensionConfig = loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: cwd, + }); + + const newExtensionName = newExtensionConfig.name; + const extensionStorage = new ExtensionStorage(newExtensionName); + const destinationPath = extensionStorage.getExtensionDir(); + + const installedExtensions = loadUserExtensions(); + if ( + installedExtensions.some( + (installed) => installed.config.name === newExtensionName, + ) + ) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } + await maybeRequestConsentOrFail( + newExtensionConfig, + requestConsent, + previousExtensionConfig, + ); + await fs.promises.mkdir(destinationPath, { recursive: true }); + + 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); + } finally { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + } + + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig!.name, + newExtensionConfig!.version, + installMetadata.source, + 'success', + ), + ); + + enableExtension(newExtensionConfig!.name, SettingScope.User); + return newExtensionConfig!.name; + } 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 = loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: cwd, + }); + } catch { + // Ignore error, this is just for logging. + } + } + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig?.name ?? '', + newExtensionConfig?.version ?? '', + installMetadata.source, + 'error', + ), + ); + throw error; + } +} + +/** + * Builds a consent string for installing an extension based on it's + * extensionConfig. + */ +function extensionConsentString(extensionConfig: ExtensionConfig): string { + const output: string[] = []; + const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); + output.push(`Installing extension "${extensionConfig.name}".`); + output.push( + '**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**', + ); + + 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 (extensionConfig.contextFileName) { + output.push( + `This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`, + ); + } + if (extensionConfig.excludeTools) { + output.push( + `This extension will exclude the following core tools: ${extensionConfig.excludeTools}`, + ); + } + return output.join('\n'); +} - return newExtensionName; +/** + * 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. + */ +async function maybeRequestConsentOrFail( + extensionConfig: ExtensionConfig, + requestConsent: (consent: string) => Promise, + previousExtensionConfig?: ExtensionConfig, +) { + const extensionConsent = extensionConsentString(extensionConfig); + if (previousExtensionConfig) { + const previousExtensionConsent = extensionConsentString( + previousExtensionConfig, + ); + if (previousExtensionConsent === extensionConsent) { + return; + } + } + if (!(await requestConsent(extensionConsent))) { + throw new Error(`Installation cancelled for "${extensionConfig.name}".`); + } +} + +export 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 function loadExtensionConfig( + context: LoadExtensionContext, +): ExtensionConfig { + const { extensionDir, workspaceDir } = context; + const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (!fs.existsSync(configFilePath)) { + throw new Error(`Configuration file not found at ${configFilePath}`); + } + try { + const configContent = fs.readFileSync(configFilePath, 'utf-8'); + const config = recursivelyHydrateStrings(JSON.parse(configContent), { + extensionPath: extensionDir, + workspacePath: workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + }) as unknown as ExtensionConfig; + if (!config.name || !config.version) { + throw new Error( + `Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`, + ); + } + validateName(config.name); + return config; + } catch (e) { + throw new Error( + `Failed to load extension config from ${configFilePath}: ${getErrorMessage( + e, + )}`, + ); + } } export async function uninstallExtension( - extensionName: string, + extensionIdentifier: string, cwd: string = process.cwd(), ): Promise { + const telemetryConfig = getTelemetryConfig(cwd); const installedExtensions = loadUserExtensions(); - if ( - !installedExtensions.some( - (installed) => installed.config.name === extensionName, - ) - ) { - throw new Error(`Extension "${extensionName}" not found.`); + const extensionName = installedExtensions.find( + (installed) => + installed.config.name.toLowerCase() === + extensionIdentifier.toLowerCase() || + installed.installMetadata?.source.toLowerCase() === + extensionIdentifier.toLowerCase(), + )?.config.name; + if (!extensionName) { + throw new Error(`Extension not found.`); } - removeFromDisabledExtensions( - extensionName, - [SettingScope.User, SettingScope.Workspace], - cwd, + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + [extensionName], ); + manager.remove(extensionName); const storage = new ExtensionStorage(extensionName); - return await fs.promises.rm(storage.getExtensionDir(), { + + await fs.promises.rm(storage.getExtensionDir(), { recursive: true, force: true, }); + logExtensionUninstall( + telemetryConfig, + new ExtensionUninstallEvent(extensionName, 'success'), + ); } -export function toOutputString(extension: Extension): string { - let output = `${extension.config.name} (${extension.config.version})`; +export function toOutputString( + extension: Extension, + workspaceDir: string, +): string { + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const userEnabled = manager.isEnabled(extension.config.name, os.homedir()); + const workspaceEnabled = manager.isEnabled( + extension.config.name, + workspaceDir, + ); + + const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); + let output = `${status} ${extension.config.name} (${extension.config.version})`; output += `\n Path: ${extension.path}`; if (extension.installMetadata) { - output += `\n Source: ${extension.installMetadata.source}`; + 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) => { @@ -438,52 +743,30 @@ export function toOutputString(extension: Extension): string { return output; } -export async function updateExtension( - extensionName: string, +export function disableExtension( + name: string, + scope: SettingScope, cwd: string = process.cwd(), -): Promise { - const installedExtensions = loadUserExtensions(); - const extension = installedExtensions.find( - (installed) => installed.config.name === extensionName, - ); +) { + const config = getTelemetryConfig(cwd); + if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { + throw new Error('System and SystemDefaults scopes are not supported.'); + } + const extension = loadExtensionByName(name, cwd); if (!extension) { - throw new Error( - `Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`, - ); + throw new Error(`Extension with name ${name} does not exist.`); } - if (!extension.installMetadata) { - throw new Error( - `Extension cannot be updated because it is missing the .qwen-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`, - ); - } - const originalVersion = extension.config.version; - const tempDir = await ExtensionStorage.createTmpDir(); - try { - await copyExtension(extension.path, tempDir); - await uninstallExtension(extensionName, cwd); - await installExtension(extension.installMetadata, cwd); - const updatedExtension = loadExtension(extension.path); - if (!updatedExtension) { - throw new Error('Updated extension not found after installation.'); - } - const updatedVersion = updatedExtension.config.version; - return { - originalVersion, - updatedVersion, - }; - } catch (e) { - console.error( - `Error updating extension, rolling back. ${getErrorMessage(e)}`, - ); - await copyExtension(tempDir, extension.path); - throw e; - } finally { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - } + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + [name], + ); + const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); + manager.disable(name, true, scopePath); + logExtensionDisable(config, new ExtensionDisableEvent(name, scope)); } -export function disableExtension( +export function enableExtension( name: string, scope: SettingScope, cwd: string = process.cwd(), @@ -491,43 +774,15 @@ export function disableExtension( if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const settings = loadSettings(cwd); - const settingsFile = settings.forScope(scope); - const extensionSettings = settingsFile.settings.extensions || { - disabled: [], - }; - const disabledExtensions = extensionSettings.disabled || []; - if (!disabledExtensions.includes(name)) { - disabledExtensions.push(name); - extensionSettings.disabled = disabledExtensions; - settings.setValue(scope, 'extensions', extensionSettings); - } -} - -export function enableExtension(name: string, scopes: SettingScope[]) { - removeFromDisabledExtensions(name, scopes); -} - -/** - * Removes an extension from the list of disabled extensions. - * @param name The name of the extension to remove. - * @param scope The scopes to remove the name from. - */ -function removeFromDisabledExtensions( - name: string, - scopes: SettingScope[], - cwd: string = process.cwd(), -) { - const settings = loadSettings(cwd); - for (const scope of scopes) { - const settingsFile = settings.forScope(scope); - const extensionSettings = settingsFile.settings.extensions || { - disabled: [], - }; - const disabledExtensions = extensionSettings.disabled || []; - extensionSettings.disabled = disabledExtensions.filter( - (extension) => extension !== name, - ); - settings.setValue(scope, 'extensions', extensionSettings); + const extension = loadExtensionByName(name, cwd); + if (!extension) { + throw new Error(`Extension with name ${name} does not exist.`); } + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); + manager.enable(name, true, scopePath); + const config = getTelemetryConfig(cwd); + logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); } diff --git a/packages/cli/src/config/extensions/extensionEnablement.test.ts b/packages/cli/src/config/extensions/extensionEnablement.test.ts new file mode 100644 index 00000000..b87fc2d8 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.test.ts @@ -0,0 +1,424 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; +import type { Extension } from '../extension.js'; + +// Helper to create a temporary directory for testing +function createTestDir() { + const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + return { + path: dirPath, + cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }), + }; +} + +let testDir: { path: string; cleanup: () => void }; +let configDir: string; +let manager: ExtensionEnablementManager; + +describe('ExtensionEnablementManager', () => { + beforeEach(() => { + testDir = createTestDir(); + configDir = path.join(testDir.path, '.gemini'); + manager = new ExtensionEnablementManager(configDir); + }); + + afterEach(() => { + testDir.cleanup(); + // 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', () => { + 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('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 enable a path based on an enable override', () => { + 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 ignore subdirs', () => { + 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(configDir, ['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(configDir).isEnabled('ext-test', '/'), + ).toBe(false); + }); + + it('disable all other extensions', () => { + manager = new ExtensionEnablementManager(configDir, ['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(configDir).isEnabled('ext-test-2', '/'), + ).toBe(true); + }); + + it('none disables all extensions', () => { + manager = new ExtensionEnablementManager(configDir, ['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(configDir).isEnabled('ext-test', '/'), + ).toBe(true); + }); + }); + + describe('validateExtensionOverrides', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should not log an error if enabledExtensionNamesOverride is empty', () => { + const manager = new ExtensionEnablementManager(configDir, []); + manager.validateExtensionOverrides([]); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should not log an error if all enabledExtensionNamesOverride are valid', () => { + const manager = new ExtensionEnablementManager(configDir, [ + 'ext-one', + 'ext-two', + ]); + const extensions = [ + { config: { name: 'ext-one' } }, + { config: { name: 'ext-two' } }, + ] as Extension[]; + manager.validateExtensionOverrides(extensions); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => { + const manager = new ExtensionEnablementManager(configDir, [ + 'ext-one', + 'ext-invalid', + 'ext-another-invalid', + ]); + const extensions = [ + { config: { name: 'ext-one' } }, + { config: { name: 'ext-two' } }, + ] as Extension[]; + manager.validateExtensionOverrides(extensions); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Extension not found: ext-invalid', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Extension not found: ext-another-invalid', + ); + }); + + it('should not log an error if "none" is in enabledExtensionNamesOverride', () => { + const manager = new ExtensionEnablementManager(configDir, ['none']); + manager.validateExtensionOverrides([]); + expect(consoleErrorSpy).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 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/`); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts new file mode 100644 index 00000000..737bc08d --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { type Extension } from '../extension.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(configDir: string, enabledExtensionNames?: string[]) { + this.configDir = configDir; + this.configFilePath = path.join(configDir, 'extension-enablement.json'); + this.enabledExtensionNamesOverride = + enabledExtensionNames?.map((name) => name.toLowerCase()) ?? []; + } + + validateExtensionOverrides(extensions: Extension[]) { + for (const name of this.enabledExtensionNamesOverride) { + if (name === 'none') continue; + if ( + !extensions.some( + (ext) => ext.config.name.toLowerCase() === name.toLowerCase(), + ) + ) { + console.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'); + return JSON.parse(content); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + console.error('Error reading 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); + } + } +} diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts new file mode 100644 index 00000000..3bd1f1fd --- /dev/null +++ b/packages/cli/src/config/extensions/github.test.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkForExtensionUpdate, + cloneFromGit, + extractFile, + findReleaseAsset, + parseGitHubRepoForReleases, +} 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/promises'; +import * as fsSync from 'node:fs'; +import * as path from 'node:path'; +import * as tar from 'tar'; +import * as archiver from 'archiver'; +import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core'; + +const mockPlatform = vi.hoisted(() => vi.fn()); +const mockArch = vi.hoisted(() => vi.fn()); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + platform: mockPlatform, + arch: mockArch, + }; +}); + +vi.mock('simple-git'); + +describe('git extension helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('cloneFromGit', () => { + const mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should clone, fetch and checkout a repo', async () => { + const installMetadata = { + source: 'http://my-repo.com', + ref: 'my-ref', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ + '--depth', + '1', + ]); + expect(mockGit.getRemotes).toHaveBeenCalledWith(true); + expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref'); + expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); + }); + + it('should use HEAD if ref is not provided', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD'); + }); + + it('should throw if no remotes are found', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([]); + + await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow( + 'Failed to clone Git repository from http://my-repo.com', + ); + }); + + it('should throw on clone error', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const destination = '/dest'; + mockGit.clone.mockRejectedValue(new Error('clone failed')); + + await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow( + 'Failed to clone Git repository from http://my-repo.com', + ); + }); + }); + + describe('checkForExtensionUpdate', () => { + const mockGit = { + getRemotes: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should return NOT_UPDATABLE for non-git extensions', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'link', + source: '', + }, + }; + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return ERROR if no remotes found', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: '', + }, + }; + mockGit.getRemotes.mockResolvedValue([]); + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + + it('should return UPDATE_AVAILABLE when remote hash is different', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, + }; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('local-hash'); + + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UP_TO_DATE when remote and local hashes are the same', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, + }; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return ERROR on git error', async () => { + const extension: GeminiCLIExtension = { + name: 'test', + path: '/ext', + version: '1.0.0', + isActive: true, + installMetadata: { + type: 'git', + source: 'my/ext', + }, + }; + mockGit.getRemotes.mockRejectedValue(new Error('git error')); + + let result: ExtensionUpdateState | undefined = undefined; + await checkForExtensionUpdate( + extension, + (newState) => (result = newState), + ); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + }); + + describe('findReleaseAsset', () => { + const assets = [ + { name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' }, + { name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' }, + { name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' }, + { name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' }, + { name: 'extension-generic.tar.gz', browser_download_url: 'url5' }, + ]; + + it('should find asset matching platform and architecture', () => { + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(assets); + expect(result).toEqual(assets[0]); + }); + + it('should find asset matching platform if arch does not match', () => { + mockPlatform.mockReturnValue('linux'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(assets); + expect(result).toEqual(assets[2]); + }); + + it('should return undefined if no matching asset is found', () => { + mockPlatform.mockReturnValue('sunos'); + mockArch.mockReturnValue('x64'); + const result = findReleaseAsset(assets); + expect(result).toBeUndefined(); + }); + + it('should find generic asset if it is the only one', () => { + const singleAsset = [ + { name: 'extension.tar.gz', browser_download_url: 'url' }, + ]; + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(singleAsset); + expect(result).toEqual(singleAsset[0]); + }); + + it('should return undefined if multiple generic assets exist', () => { + const multipleGenericAssets = [ + { name: 'extension-1.tar.gz', browser_download_url: 'url1' }, + { name: 'extension-2.tar.gz', browser_download_url: 'url2' }, + ]; + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + const result = findReleaseAsset(multipleGenericAssets); + expect(result).toBeUndefined(); + }); + }); + + describe('parseGitHubRepoForReleases', () => { + it('should parse owner and repo from a full GitHub URL', () => { + const source = 'https://github.com/owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should parse owner and repo from a full GitHub UR without .git', () => { + const source = 'https://github.com/owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should fail on a GitHub SSH URL', () => { + const source = 'git@github.com:owner/repo.git'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.', + ); + }); + + it('should fail on a non-GitHub URL', () => { + const source = 'https://example.com/owner/repo.git'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: https://example.com/owner/repo.git. Expected "owner/repo" or a github repo uri.', + ); + }); + + it('should parse owner and repo from a shorthand string', () => { + const source = 'owner/repo'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should handle .git suffix in repo name', () => { + const source = 'owner/repo.git'; + const { owner, repo } = parseGitHubRepoForReleases(source); + expect(owner).toBe('owner'); + expect(repo).toBe('repo'); + }); + + it('should throw error for invalid source format', () => { + const source = 'invalid-format'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.', + ); + }); + + it('should throw error for source with too many parts', () => { + const source = 'https://github.com/owner/repo/extra'; + expect(() => parseGitHubRepoForReleases(source)).toThrow( + 'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.', + ); + }); + }); + + describe('extractFile', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should extract a .tar.gz file', async () => { + const archivePath = path.join(tempDir, 'test.tar.gz'); + const extractionDest = path.join(tempDir, 'extracted'); + await fs.mkdir(extractionDest); + + // Create a dummy file to be archived + const dummyFilePath = path.join(tempDir, 'test.txt'); + await fs.writeFile(dummyFilePath, 'hello tar'); + + // Create the tar.gz file + await tar.c( + { + gzip: true, + file: archivePath, + cwd: tempDir, + }, + ['test.txt'], + ); + + await extractFile(archivePath, extractionDest); + + const extractedFilePath = path.join(extractionDest, 'test.txt'); + const content = await fs.readFile(extractedFilePath, 'utf-8'); + expect(content).toBe('hello tar'); + }); + + it('should extract a .zip file', async () => { + const archivePath = path.join(tempDir, 'test.zip'); + const extractionDest = path.join(tempDir, 'extracted'); + await fs.mkdir(extractionDest); + + // Create a dummy file to be archived + const dummyFilePath = path.join(tempDir, 'test.txt'); + await fs.writeFile(dummyFilePath, 'hello zip'); + + // Create the zip file + const output = fsSync.createWriteStream(archivePath); + const archive = archiver.create('zip'); + + const streamFinished = new Promise((resolve, reject) => { + output.on('close', () => resolve(null)); + archive.on('error', reject); + }); + + archive.pipe(output); + archive.file(dummyFilePath, { name: 'test.txt' }); + await archive.finalize(); + await streamFinished; + + await extractFile(archivePath, extractionDest); + + const extractedFilePath = path.join(extractionDest, 'test.txt'); + const content = await fs.readFile(extractedFilePath, 'utf-8'); + expect(content).toBe('hello zip'); + }); + + it('should throw an error for unsupported file types', async () => { + const unsupportedFilePath = path.join(tempDir, 'test.txt'); + await fs.writeFile(unsupportedFilePath, 'some content'); + const extractionDest = path.join(tempDir, 'extracted'); + await fs.mkdir(extractionDest); + + await expect( + extractFile(unsupportedFilePath, extractionDest), + ).rejects.toThrow('Unsupported file extension for extraction:'); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts new file mode 100644 index 00000000..9bdcb648 --- /dev/null +++ b/packages/cli/src/config/extensions/github.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { simpleGit } from 'simple-git'; +import { getErrorMessage } from '../../utils/errors.js'; +import type { + ExtensionInstallMetadata, + GeminiCLIExtension, +} from '@qwen-code/qwen-code-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 { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js'; +import * as tar from 'tar'; +import extract from 'extract-zip'; + +function getGitHubToken(): string | undefined { + return process.env['GITHUB_TOKEN']; +} + +/** + * 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 function parseGitHubRepoForReleases(source: string): { + owner: string; + repo: string; +} { + // Default to a github repo path, so `source` can be just an org/repo + const parsedUrl = URL.parse(source, 'https://github.com'); + // The pathname should be "/owner/repo". + const parts = parsedUrl?.pathname.substring(1).split('/'); + if (parts?.length !== 2 || parsedUrl?.host !== 'github.com') { + 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', ''); + + if (owner.startsWith('git@github.com')) { + throw new Error( + `GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`, + ); + } + + return { owner, repo }; +} + +async function fetchReleaseFromGithub( + owner: string, + repo: string, + ref?: string, +): Promise { + const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest'; + const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`; + return await fetchJson(url); +} + +export async function checkForExtensionUpdate( + extension: GeminiCLIExtension, + setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, + cwd: string = process.cwd(), +): Promise { + setExtensionUpdateState(ExtensionUpdateState.CHECKING_FOR_UPDATES); + const installMetadata = extension.installMetadata; + if (installMetadata?.type === 'local') { + const newExtension = loadExtension({ + extensionDir: installMetadata.source, + workspaceDir: cwd, + }); + if (!newExtension) { + console.error( + `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + if (newExtension.config.version !== extension.version) { + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + if ( + !installMetadata || + (installMetadata.type !== 'git' && + installMetadata.type !== 'github-release') + ) { + setExtensionUpdateState(ExtensionUpdateState.NOT_UPDATABLE); + return; + } + try { + if (installMetadata.type === 'git') { + const git = simpleGit(extension.path); + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + console.error('No git remotes found.'); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + const remoteUrl = remotes[0].refs.fetch; + if (!remoteUrl) { + console.error(`No fetch URL found for git remote ${remotes[0].name}.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + + // 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() === '') { + console.error(`Git ref ${refToCheck} not found.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + + const remoteHash = lsRemoteOutput.split('\t')[0]; + const localHash = await git.revparse(['HEAD']); + + if (!remoteHash) { + console.error( + `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + if (remoteHash === localHash) { + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } else { + const { source, releaseTag } = installMetadata; + if (!source) { + console.error(`No "source" provided for extension.`); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } + const { owner, repo } = parseGitHubRepoForReleases(source); + + const releaseData = await fetchReleaseFromGithub( + owner, + repo, + installMetadata.ref, + ); + if (releaseData.tag_name !== releaseTag) { + setExtensionUpdateState(ExtensionUpdateState.UPDATE_AVAILABLE); + return; + } + setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE); + return; + } + } catch (error) { + console.error( + `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, + ); + setExtensionUpdateState(ExtensionUpdateState.ERROR); + return; + } +} +export interface GitHubDownloadResult { + tagName: string; + type: 'git' | 'github-release'; +} +export async function downloadFromGitHubRelease( + installMetadata: ExtensionInstallMetadata, + destination: string, +): Promise { + const { source, ref } = installMetadata; + const { owner, repo } = parseGitHubRepoForReleases(source); + + try { + const releaseData = await fetchReleaseFromGithub(owner, repo, ref); + if (!releaseData) { + throw new Error( + `No release data found for ${owner}/${repo} at tag ${ref}`, + ); + } + + const asset = findReleaseAsset(releaseData.assets); + let archiveUrl: string | undefined; + let isTar = false; + let isZip = false; + if (asset) { + archiveUrl = asset.browser_download_url; + } else { + if (releaseData.tarball_url) { + archiveUrl = releaseData.tarball_url; + isTar = true; + } else if (releaseData.zipball_url) { + archiveUrl = releaseData.zipball_url; + isZip = true; + } + } + if (!archiveUrl) { + throw new Error( + `No assets found for release with tag ${releaseData.tag_name}`, + ); + } + let downloadedAssetPath = path.join( + destination, + path.basename(new URL(archiveUrl).pathname), + ); + if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) { + downloadedAssetPath += '.tar.gz'; + } else if (isZip && !downloadedAssetPath.endsWith('.zip')) { + downloadedAssetPath += '.zip'; + } + + await downloadFile(archiveUrl, downloadedAssetPath); + + await extractFile(downloadedAssetPath, destination); + + // 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 qwen 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', + }; + } catch (error) { + throw new Error( + `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; + browser_download_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; +} + +async function fetchJson(url: string): 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 !== 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(); + resolve(JSON.parse(data) as T); + }); + }) + .on('error', reject); + }); +} + +async function downloadFile(url: string, dest: string): 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) { + downloadFile(res.headers.location!, dest).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}`); + } +} diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts new file mode 100644 index 00000000..3e916ec9 --- /dev/null +++ b/packages/cli/src/config/extensions/update.test.ts @@ -0,0 +1,457 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + ExtensionStorage, + INSTALL_METADATA_FILENAME, + annotateActiveExtensions, + loadExtension, +} from '../extension.js'; +import { checkForAllExtensionUpdates, updateExtension } from './update.js'; +import { QWEN_DIR } from '@qwen-code/qwen-code-core'; +import { isWorkspaceTrusted } from '../trustedFolders.js'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import { createExtension } from '../../test-utils/createExtension.js'; +import { ExtensionEnablementManager } from './extensionEnablement.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(), +}; + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn((path: string) => { + mockGit.path.mockReturnValue(path); + return mockGit; + }), +})); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +vi.mock('../trustedFolders.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: vi.fn(), + }; +}); + +const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); +const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logExtensionInstallEvent: mockLogExtensionInstallEvent, + logExtensionUninstall: mockLogExtensionUninstall, + ExtensionInstallEvent: vi.fn(), + ExtensionUninstallEvent: vi.fn(), + }; +}); + +describe('update tests', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let userExtensionsDir: string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'qwen-code-test-home-'), + ); + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'qwen-code-test-workspace-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, QWEN_DIR, 'extensions'); + // Clean up before each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + Object.values(mockGit).forEach((fn) => fn.mockReset()); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + }); + + describe('updateExtension', () => { + it('should update a git-installed extension', async () => { + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'qwen-extensions'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + fs.mkdirSync(targetExtDir, { recursive: true }); + fs.writeFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + fs.writeFileSync( + metadataPath, + JSON.stringify({ source: gitUrl, type: 'git' }), + ); + + 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.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: targetExtDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + const updateInfo = await updateExtension( + extension, + tempHomeDir, + async (_) => true, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + + expect(updateInfo).toEqual({ + name: 'qwen-extensions', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + const updatedConfig = JSON.parse( + fs.readFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ); + expect(updatedConfig.version).toBe('1.1.0'); + }); + + it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + 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.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const dispatch = vi.fn(); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + await updateExtension( + extension, + tempHomeDir, + async (_) => true, + ExtensionUpdateState.UPDATE_AVAILABLE, + dispatch, + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: extensionName, + state: ExtensionUpdateState.UPDATING, + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: extensionName, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + }); + + it('should call setExtensionUpdateState with ERROR on failure', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockRejectedValue(new Error('Git clone failed')); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const dispatch = vi.fn(); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + await expect( + updateExtension( + extension, + tempHomeDir, + async (_) => true, + ExtensionUpdateState.UPDATE_AVAILABLE, + dispatch, + ), + ).rejects.toThrow(); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: extensionName, + state: ExtensionUpdateState.UPDATING, + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: extensionName, + state: ExtensionUpdateState.ERROR, + }, + }); + }); + }); + + describe('checkForAllExtensionUpdates', () => { + it('should return UpdateAvailable for a git extension with updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); + mockGit.revparse.mockResolvedValue('localHash'); + + const dispatch = vi.fn(); + await checkForAllExtensionUpdates([extension], dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: 'test-extension', + state: ExtensionUpdateState.UPDATE_AVAILABLE, + }, + }); + }); + + it('should return UpToDate for a git extension with no updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('sameHash HEAD'); + mockGit.revparse.mockResolvedValue('sameHash'); + + const dispatch = vi.fn(); + await checkForAllExtensionUpdates([extension], dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: 'test-extension', + state: ExtensionUpdateState.UP_TO_DATE, + }, + }); + }); + + it('should return UpToDate for a local extension with no updates', async () => { + const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); + const sourceExtensionDir = createExtension({ + extensionsDir: localExtensionSourcePath, + name: 'my-local-ext', + version: '1.0.0', + }); + + const installedExtensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: sourceExtensionDir, type: 'local' }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: installedExtensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + const dispatch = vi.fn(); + await checkForAllExtensionUpdates([extension], dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: 'local-extension', + state: ExtensionUpdateState.UP_TO_DATE, + }, + }); + }); + + it('should return UpdateAvailable for a local extension with updates', async () => { + const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); + const sourceExtensionDir = createExtension({ + extensionsDir: localExtensionSourcePath, + name: 'my-local-ext', + version: '1.1.0', + }); + + const installedExtensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: sourceExtensionDir, type: 'local' }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir: installedExtensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + const dispatch = vi.fn(); + await checkForAllExtensionUpdates([extension], dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: 'local-extension', + state: ExtensionUpdateState.UPDATE_AVAILABLE, + }, + }); + }); + + it('should return Error when git check fails', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'error-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], + process.cwd(), + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + )[0]; + + mockGit.getRemotes.mockRejectedValue(new Error('Git error')); + + const dispatch = vi.fn(); + await checkForAllExtensionUpdates([extension], dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: 'error-extension', + state: ExtensionUpdateState.ERROR, + }, + }); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts new file mode 100644 index 00000000..d74be540 --- /dev/null +++ b/packages/cli/src/config/extensions/update.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ExtensionUpdateAction, + ExtensionUpdateState, + type ExtensionUpdateStatus, +} from '../../ui/state/extensions.js'; +import { + copyExtension, + installExtension, + uninstallExtension, + loadExtension, + loadInstallMetadata, + ExtensionStorage, + loadExtensionConfig, +} from '../extension.js'; +import { checkForExtensionUpdate } from './github.js'; +import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core'; +import * as fs from 'node:fs'; +import { getErrorMessage } from '../../utils/errors.js'; + +export interface ExtensionUpdateInfo { + name: string; + originalVersion: string; + updatedVersion: string; +} + +export async function updateExtension( + extension: GeminiCLIExtension, + cwd: string = process.cwd(), + requestConsent: (consent: string) => Promise, + currentState: ExtensionUpdateState, + dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void, +): 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.`, + ); + } + 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`); + } + const originalVersion = extension.version; + + const tempDir = await ExtensionStorage.createTmpDir(); + try { + await copyExtension(extension.path, tempDir); + const previousExtensionConfig = await loadExtensionConfig({ + extensionDir: extension.path, + workspaceDir: cwd, + }); + await uninstallExtension(extension.name, cwd); + await installExtension( + installMetadata, + requestConsent, + cwd, + previousExtensionConfig, + ); + + const updatedExtensionStorage = new ExtensionStorage(extension.name); + const updatedExtension = loadExtension({ + extensionDir: updatedExtensionStorage.getExtensionDir(), + workspaceDir: cwd, + }); + if (!updatedExtension) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error('Updated extension not found after installation.'); + } + const updatedVersion = updatedExtension.config.version; + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { + name: extension.name, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + return { + name: extension.name, + originalVersion, + updatedVersion, + }; + } catch (e) { + console.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( + cwd: string = process.cwd(), + requestConsent: (consent: string) => Promise, + extensions: GeminiCLIExtension[], + extensionsState: Map, + dispatch: (action: ExtensionUpdateAction) => void, +): Promise { + return ( + await Promise.all( + extensions + .filter( + (extension) => + extensionsState.get(extension.name)?.status === + ExtensionUpdateState.UPDATE_AVAILABLE, + ) + .map((extension) => + updateExtension( + extension, + cwd, + requestConsent, + extensionsState.get(extension.name)!.status, + dispatch, + ), + ), + ) + ).filter((updateInfo) => !!updateInfo); +} + +export interface ExtensionUpdateCheckResult { + state: ExtensionUpdateState; + error?: string; +} + +export async function checkForAllExtensionUpdates( + extensions: GeminiCLIExtension[], + dispatch: (action: ExtensionUpdateAction) => void, +): Promise { + dispatch({ type: 'BATCH_CHECK_START' }); + const promises: Array> = []; + for (const extension of extensions) { + if (!extension.installMetadata) { + dispatch({ + type: 'SET_STATE', + payload: { + name: extension.name, + state: ExtensionUpdateState.NOT_UPDATABLE, + }, + }); + continue; + } + promises.push( + checkForExtensionUpdate(extension, (updatedState) => { + dispatch({ + type: 'SET_STATE', + payload: { name: extension.name, state: updatedState }, + }); + }), + ); + } + await Promise.all(promises); + dispatch({ type: 'BATCH_CHECK_END' }); +} diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts index e55f2a52..f38e1b1f 100644 --- a/packages/cli/src/config/extensions/variableSchema.ts +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -15,6 +15,11 @@ export interface VariableSchema { [key: string]: VariableDefinition; } +export interface LoadExtensionContext { + extensionDir: string; + workspaceDir: string; +} + const PATH_SEPARATOR_DEFINITION = { type: 'string', description: 'The path separator.', @@ -25,6 +30,10 @@ export const VARIABLE_SCHEMA = { 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; diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index ed1301ea..b261d7aa 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -20,6 +20,7 @@ export enum Command { KILL_LINE_RIGHT = 'killLineRight', KILL_LINE_LEFT = 'killLineLeft', CLEAR_INPUT = 'clearInput', + DELETE_WORD_BACKWARD = 'deleteWordBackward', // Screen control CLEAR_SCREEN = 'clearScreen', @@ -55,6 +56,11 @@ export enum Command { REVERSE_SEARCH = 'reverseSearch', SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', + TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', + + // Suggestion expansion + EXPAND_SUGGESTION = 'expandSuggestion', + COLLAPSE_SUGGESTION = 'collapseSuggestion', } /** @@ -89,46 +95,38 @@ export type KeyBindingConfig = { export const defaultKeyBindings: KeyBindingConfig = { // Basic bindings [Command.RETURN]: [{ key: 'return' }], - // Original: key.name === 'escape' [Command.ESCAPE]: [{ key: 'escape' }], // Cursor movement - // Original: key.ctrl && key.name === 'a' [Command.HOME]: [{ key: 'a', ctrl: true }], - // Original: key.ctrl && key.name === 'e' [Command.END]: [{ key: 'e', ctrl: true }], // Text deletion - // Original: key.ctrl && key.name === 'k' [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], - // Original: key.ctrl && key.name === 'u' [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - // Original: key.ctrl && key.name === 'c' [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + // Added command (meta/alt/option) for mac compatibility + [Command.DELETE_WORD_BACKWARD]: [ + { key: 'backspace', ctrl: true }, + { key: 'backspace', command: true }, + ], // Screen control - // Original: key.ctrl && key.name === 'l' [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], // History navigation - // Original: key.ctrl && key.name === 'p' [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }], - // Original: key.ctrl && key.name === 'n' [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }], - // Original: key.name === 'up' [Command.NAVIGATION_UP]: [{ key: 'up' }], - // Original: key.name === 'down' [Command.NAVIGATION_DOWN]: [{ key: 'down' }], // Auto-completion - // Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl) [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], // Completion navigation (arrow or Ctrl+P/N) [Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }], [Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }], // Text input - // Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste // Must also exclude shift to allow shift+enter for newline [Command.SUBMIT]: [ { @@ -139,7 +137,6 @@ export const defaultKeyBindings: KeyBindingConfig = { shift: false, }, ], - // Original: key.name === 'return' && (key.ctrl || key.meta || key.paste) // Split into multiple data-driven bindings // Now also includes shift+enter for multi-line input [Command.NEWLINE]: [ @@ -151,34 +148,28 @@ export const defaultKeyBindings: KeyBindingConfig = { ], // External tools - // Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18') [Command.OPEN_EXTERNAL_EDITOR]: [ { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - // Original: key.ctrl && key.name === 'v' [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], // App level bindings - // Original: key.ctrl && key.name === 'o' [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], - // Original: key.ctrl && key.name === 't' [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], - // Original: key.ctrl && key.name === 'g' [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - // Original: key.ctrl && (key.name === 'c' || key.name === 'C') [Command.QUIT]: [{ key: 'c', ctrl: true }], - // Original: key.ctrl && (key.name === 'd' || key.name === 'D') [Command.EXIT]: [{ key: 'd', ctrl: true }], - // Original: key.ctrl && key.name === 's' [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], // Shell commands - // Original: key.ctrl && key.name === 'r' [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Original: key.name === 'return' && !key.ctrl // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - // Original: key.name === 'tab' [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], + [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], + + // Suggestion expansion + [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], + [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], }; diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 89720114..f5971b5f 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -30,11 +30,13 @@ vi.mock('./settings.js', async (importActual) => { // Mock trustedFolders vi.mock('./trustedFolders.js', () => ({ - isWorkspaceTrusted: vi.fn(), + isWorkspaceTrusted: vi + .fn() + .mockReturnValue({ isTrusted: true, source: 'file' }), })); // NOW import everything else, including the (now effectively re-exported) settings.js -import * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH +import path, * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH import { describe, it, @@ -44,10 +46,12 @@ import { afterEach, type Mocked, type Mock, + fail, } 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'; +import { disableExtension } from './extension.js'; // These imports will get the versions from the vi.mock('./settings.js', ...) factory. import { @@ -57,8 +61,13 @@ import { getSystemDefaultsPath, SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock. migrateSettingsToV1, + needsMigration, type Settings, + loadEnvironment, + migrateDeprecatedSettings, + SettingScope, } from './settings.js'; +import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; // Use the (mocked) SETTINGS_DIRECTORY_NAME for consistency @@ -90,6 +99,10 @@ vi.mock('fs', async (importOriginal) => { }; }); +vi.mock('./extension.js', () => ({ + disableExtension: vi.fn(), +})); + vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); @@ -113,7 +126,10 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(false); (fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON (mockFsMkdirSync as Mock).mockImplementation(() => undefined); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); }); afterEach(() => { @@ -126,36 +142,7 @@ describe('Settings Loading and Merging', () => { expect(settings.system.settings).toEqual({}); expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual({}); - expect(settings.merged).toEqual({ - ui: { - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - context: { - includeDirectories: [], - }, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - tools: {}, - ide: {}, - }); - expect(settings.errors.length).toBe(0); + expect(settings.merged).toEqual({}); }); it('should load system settings if only system file exists', () => { @@ -189,36 +176,6 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ ...systemSettingsContent, - ui: { - ...systemSettingsContent.ui, - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - context: { - includeDirectories: [], - }, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - tools: { - sandbox: false, - }, - ide: {}, }); }); @@ -254,35 +211,6 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual({}); expect(settings.merged).toEqual({ ...userSettingsContent, - ui: { - ...userSettingsContent.ui, - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - context: { - ...userSettingsContent.context, - includeDirectories: [], - }, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - tools: {}, - ide: {}, }); }); @@ -315,114 +243,17 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual({}); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - tools: { - sandbox: true, - }, - context: { - fileName: 'WORKSPACE_CONTEXT.md', - includeDirectories: [], - }, - ui: { - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - ide: {}, - }); - }); - - it('should merge user and workspace settings, with workspace taking precedence', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); - const userSettingsContent = { - ui: { - theme: 'dark', - }, - tools: { - sandbox: false, - }, - context: { - fileName: 'USER_CONTEXT.md', - }, - }; - const workspaceSettingsContent = { - tools: { - sandbox: true, - core: ['tool1'], - }, - context: { - fileName: 'WORKSPACE_CONTEXT.md', - }, - }; - - (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) - return JSON.stringify(userSettingsContent); - if (p === 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).toEqual({ - ui: { - theme: 'dark', - customThemes: {}, - }, - tools: { - sandbox: true, - core: ['tool1'], - }, - context: { - fileName: 'WORKSPACE_CONTEXT.md', - includeDirectories: [], - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - mcp: {}, - mcpServers: {}, - model: { - chatCompression: {}, - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - ide: {}, + ...workspaceSettingsContent, }); }); it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + p === getSystemSettingsPath() || + p === USER_SETTINGS_PATH || + p === MOCK_WORKSPACE_SETTINGS_PATH, + ); const systemSettingsContent = { ui: { theme: 'system-theme', @@ -479,7 +310,6 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ ui: { theme: 'system-theme', - customThemes: {}, }, tools: { sandbox: false, @@ -488,29 +318,10 @@ describe('Settings Loading and Merging', () => { telemetry: { enabled: false }, context: { fileName: 'WORKSPACE_CONTEXT.md', - includeDirectories: [], }, mcp: { allowed: ['server1', 'server2'], }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - mcpServers: {}, - model: { - chatCompression: {}, - }, - security: {}, - general: {}, - privacy: {}, - ide: {}, }); }); @@ -552,18 +363,15 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ ui: { theme: 'legacy-dark', - customThemes: {}, }, general: { vimMode: true, }, context: { fileName: 'LEGACY_CONTEXT.md', - includeDirectories: [], }, model: { name: 'gemini-pro', - chatCompression: {}, }, mcpServers: { 'legacy-server-1': { @@ -581,24 +389,30 @@ describe('Settings Loading and Merging', () => { allowed: ['legacy-server-1'], }, someUnrecognizedSetting: 'should-be-preserved', - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - privacy: {}, - telemetry: {}, - tools: {}, - ide: {}, }); }); + it('should rewrite allowedTools to tools.allowed during migration', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const legacySettingsContent = { + allowedTools: ['fs', 'shell'], + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(legacySettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.tools?.allowed).toEqual(['fs', 'shell']); + expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined(); + }); + it('should correctly merge and migrate legacy array properties from multiple scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const legacyUserSettings = { @@ -630,8 +444,11 @@ describe('Settings Loading and Merging', () => { '/workspace/dir', ]); - // Verify excludeTools are overwritten by workspace - expect(settings.merged.tools?.exclude).toEqual(['workspace-tool']); + // Verify excludeTools are concatenated and de-duped + expect(settings.merged.tools?.exclude).toEqual([ + 'user-tool', + 'workspace-tool', + ]); // Verify excludedProjectEnvVars are concatenated and de-duped expect(settings.merged.advanced?.excludedEnvVars).toEqual( @@ -703,9 +520,6 @@ describe('Settings Loading and Merging', () => { expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ - advanced: { - excludedEnvVars: [], - }, context: { fileName: 'WORKSPACE_CONTEXT.md', includeDirectories: [ @@ -716,34 +530,17 @@ describe('Settings Loading and Merging', () => { '/system/dir', ], }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - mcp: {}, - mcpServers: {}, - model: { - chatCompression: {}, - }, - security: {}, - telemetry: {}, + telemetry: false, tools: { sandbox: false, }, ui: { - customThemes: {}, theme: 'system-theme', }, - general: {}, - privacy: {}, - ide: {}, }); }); - it('should ignore folderTrust from workspace settings', () => { + it('should use folderTrust from workspace settings when trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { security: { @@ -755,7 +552,7 @@ describe('Settings Loading and Merging', () => { const workspaceSettingsContent = { security: { folderTrust: { - enabled: false, // This should be ignored + enabled: false, // This should be used }, }, }; @@ -776,7 +573,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used + expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used }); it('should use system folderTrust over user setting', () => { @@ -903,7 +700,10 @@ describe('Settings Loading and Merging', () => { }); it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + ); const userSettingsContent = { general: {}, advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'] }, @@ -944,7 +744,10 @@ describe('Settings Loading and Merging', () => { }); it('should default contextFileName to undefined if not in any settings file', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + ); const userSettingsContent = { ui: { theme: 'dark' } }; const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( @@ -1014,13 +817,16 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.telemetry).toEqual({}); - expect(settings.merged.ui?.customThemes).toEqual({}); - expect(settings.merged.mcpServers).toEqual({}); + expect(settings.merged.telemetry).toBeUndefined(); + expect(settings.merged.ui).toBeUndefined(); + expect(settings.merged.mcpServers).toBeUndefined(); }); it('should merge MCP servers correctly, with workspace taking precedence', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => + p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + ); const userSettingsContent = { mcpServers: { 'user-server': { @@ -1138,11 +944,11 @@ describe('Settings Loading and Merging', () => { }); }); - it('should have mcpServers as empty object if not in any settings file', () => { + 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({}); + expect(settings.merged.mcpServers).toBeUndefined(); }); it('should merge MCP servers from system, user, and workspace with system taking precedence', () => { @@ -1288,6 +1094,30 @@ describe('Settings Loading and Merging', () => { }); }); + it('should merge output format settings, with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + output: { format: 'text' }, + }; + const workspaceSettingsContent = { + output: { format: 'json' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.output?.format).toBe('json'); + }); + it('should handle chatCompression when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, @@ -1310,11 +1140,11 @@ describe('Settings Loading and Merging', () => { }); }); - it('should have chatCompression as an empty object if not in any settings file', () => { + it('should have model 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.model?.chatCompression).toEqual({}); + expect(settings.merged.model).toBeUndefined(); }); it('should ignore chatCompression if contextPercentageThreshold is invalid', () => { @@ -1439,57 +1269,22 @@ describe('Settings Loading and Merging', () => { }, ); - const settings = loadSettings(MOCK_WORKSPACE_DIR); - - // Check that settings are empty due to parsing errors - expect(settings.user.settings).toEqual({}); - expect(settings.workspace.settings).toEqual({}); - expect(settings.merged).toEqual({ - ui: { - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - context: { - includeDirectories: [], - }, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - tools: {}, - telemetry: {}, - ide: {}, - }); - - // Check that error objects are populated in settings.errors - expect(settings.errors).toBeDefined(); - // Assuming both user and workspace files cause errors and are added in order - expect(settings.errors.length).toEqual(2); - - const userError = settings.errors.find( - (e) => e.path === USER_SETTINGS_PATH, - ); - expect(userError).toBeDefined(); - expect(userError?.message).toBe(userReadError.message); - - const workspaceError = settings.errors.find( - (e) => e.path === MOCK_WORKSPACE_SETTINGS_PATH, - ); - expect(workspaceError).toBeDefined(); - expect(workspaceError?.message).toBe(workspaceReadError.message); + try { + loadSettings(MOCK_WORKSPACE_DIR); + fail('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 @@ -1904,33 +1699,6 @@ describe('Settings Loading and Merging', () => { expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.merged).toEqual({ ...systemSettingsContent, - ui: { - ...systemSettingsContent.ui, - customThemes: {}, - }, - mcp: {}, - mcpServers: {}, - context: { - includeDirectories: [], - }, - model: { - chatCompression: {}, - }, - advanced: { - excludedEnvVars: [], - }, - experimental: {}, - contentGenerator: {}, - systemPromptMappings: {}, - extensions: { - disabled: [], - workspacesWithMigrationNudge: [], - }, - security: {}, - general: {}, - privacy: {}, - telemetry: {}, - ide: {}, }); }); }); @@ -2108,7 +1876,10 @@ describe('Settings Loading and Merging', () => { }); it('should NOT merge workspace settings when workspace is not trusted', () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { ui: { theme: 'dark' }, @@ -2465,5 +2236,255 @@ describe('Settings Loading and Merging', () => { allowMCPServers: ['serverA'], }); }); + + it('should correctly migrate customWittyPhrases', () => { + const v2Settings: Partial = { + ui: { + customWittyPhrases: ['test phrase'], + }, + }; + const v1Settings = migrateSettingsToV1(v2Settings as Settings); + expect(v1Settings).toEqual({ + customWittyPhrases: ['test phrase'], + }); + }); + }); + + describe('loadEnvironment', () => { + function setup({ + isFolderTrustEnabled = true, + isWorkspaceTrustedValue = true, + }) { + delete process.env['TESTTEST']; // reset + const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env')); + + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: isWorkspaceTrustedValue, + source: 'file', + }); + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => + [USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()), + ); + const userSettingsContent: Settings = { + ui: { + theme: 'dark', + }, + security: { + folderTrust: { + enabled: isFolderTrustEnabled, + }, + }, + context: { + fileName: 'USER_CONTEXT.md', + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === geminiEnvPath) return 'TESTTEST=1234'; + return '{}'; + }, + ); + } + + it('sets environment variables from .env files', () => { + setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true }); + loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged); + + expect(process.env['TESTTEST']).toEqual('1234'); + }); + + it('does not load env files from untrusted spaces', () => { + setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false }); + loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged); + + expect(process.env['TESTTEST']).not.toEqual('1234'); + }); + }); + + describe('needsMigration', () => { + it('should return false for an empty object', () => { + expect(needsMigration({})).toBe(false); + }); + + it('should return false for settings that are already in V2 format', () => { + const v2Settings: Partial = { + ui: { + theme: 'dark', + }, + tools: { + sandbox: true, + }, + }; + expect(needsMigration(v2Settings)).toBe(false); + }); + + it('should return true for settings with a V1 key that needs to be moved', () => { + const v1Settings = { + theme: 'dark', // v1 key + }; + expect(needsMigration(v1Settings)).toBe(true); + }); + + it('should return true for settings with a mix of V1 and V2 keys', () => { + const mixedSettings = { + theme: 'dark', // v1 key + tools: { + sandbox: true, // v2 key + }, + }; + expect(needsMigration(mixedSettings)).toBe(true); + }); + + it('should return false for settings with only V1 keys that are the same in V2', () => { + const v1Settings = { + mcpServers: {}, + telemetry: {}, + extensions: [], + }; + expect(needsMigration(v1Settings)).toBe(false); + }); + + it('should return true for settings with a mix of V1 keys that are the same in V2 and V1 keys that need moving', () => { + const v1Settings = { + mcpServers: {}, // same in v2 + theme: 'dark', // needs moving + }; + expect(needsMigration(v1Settings)).toBe(true); + }); + + it('should return false for settings with unrecognized keys', () => { + const settings = { + someUnrecognizedKey: 'value', + }; + expect(needsMigration(settings)).toBe(false); + }); + + it('should return false for settings with v2 keys and unrecognized keys', () => { + const settings = { + ui: { theme: 'dark' }, + someUnrecognizedKey: 'value', + }; + expect(needsMigration(settings)).toBe(false); + }); + }); + + describe('migrateDeprecatedSettings', () => { + let mockFsExistsSync: Mocked; + let mockFsReadFileSync: Mocked; + let mockDisableExtension: Mocked; + + beforeEach(() => { + vi.resetAllMocks(); + + mockFsExistsSync = vi.mocked(fs.existsSync); + mockFsReadFileSync = vi.mocked(fs.readFileSync); + mockDisableExtension = vi.mocked(disableExtension); + + (mockFsExistsSync as Mock).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should migrate disabled extensions from user and workspace settings', () => { + const userSettingsContent = { + extensions: { + disabled: ['user-ext-1', 'shared-ext'], + }, + }; + const workspaceSettingsContent = { + extensions: { + disabled: ['workspace-ext-1', 'shared-ext'], + }, + }; + + (mockFsReadFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR); + + // Check user settings migration + expect(mockDisableExtension).toHaveBeenCalledWith( + 'user-ext-1', + SettingScope.User, + MOCK_WORKSPACE_DIR, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'shared-ext', + SettingScope.User, + MOCK_WORKSPACE_DIR, + ); + + // Check workspace settings migration + expect(mockDisableExtension).toHaveBeenCalledWith( + 'workspace-ext-1', + SettingScope.Workspace, + MOCK_WORKSPACE_DIR, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'shared-ext', + SettingScope.Workspace, + MOCK_WORKSPACE_DIR, + ); + + // Check that setValue was called to remove the deprecated setting + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'extensions', + { + disabled: undefined, + }, + ); + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.Workspace, + 'extensions', + { + disabled: undefined, + }, + ); + }); + + it('should not do anything if there are no deprecated settings', () => { + const userSettingsContent = { + extensions: { + enabled: ['user-ext-1'], + }, + }; + const workspaceSettingsContent = { + someOtherSetting: 'value', + }; + + (mockFsReadFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR); + + expect(mockDisableExtension).not.toHaveBeenCalled(); + expect(setValueSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index b22df887..3ebb5749 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -8,8 +8,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir, platform } from 'node:os'; import * as dotenv from 'dotenv'; +import process from 'node:process'; import { - GEMINI_CONFIG_DIR as GEMINI_DIR, + FatalConfigError, + QWEN_DIR, getErrorMessage, Storage, } from '@qwen-code/qwen-code-core'; @@ -17,8 +19,33 @@ import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import type { Settings, MemoryImportFormat } from './settingsSchema.js'; -import { mergeWith } from 'lodash-es'; +import { + type Settings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +} from './settingsSchema.js'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; +import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { disableExtension } from './extension.js'; + +function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { + let current: SettingDefinition | undefined = undefined; + let currentSchema: SettingsSchema | undefined = getSettingsSchema(); + + for (const key of path) { + if (!currentSchema || !currentSchema[key]) { + return undefined; + } + current = currentSchema[key]; + currentSchema = current.properties; + } + + return current?.mergeStrategy; +} export type { Settings, MemoryImportFormat }; @@ -27,56 +54,83 @@ 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 MIGRATE_V2_OVERWRITE = false; +const MIGRATE_V2_OVERWRITE = true; const MIGRATION_MAP: Record = { - preferredEditor: 'general.preferredEditor', - vimMode: 'general.vimMode', + accessibility: 'ui.accessibility', + allowedTools: 'tools.allowed', + allowMCPServers: 'mcp.allowed', + autoAccept: 'tools.autoAccept', + autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', + bugCommand: 'advanced.bugCommand', + chatCompression: 'model.chatCompression', + checkpointing: 'general.checkpointing', + coreTools: 'tools.core', + contextFileName: 'context.fileName', + customThemes: 'ui.customThemes', + customWittyPhrases: 'ui.customWittyPhrases', + debugKeystrokeLogging: 'general.debugKeystrokeLogging', disableAutoUpdate: 'general.disableAutoUpdate', disableUpdateNag: 'general.disableUpdateNag', - checkpointing: 'general.checkpointing', - theme: 'ui.theme', - customThemes: 'ui.customThemes', + dnsResolutionOrder: 'advanced.dnsResolutionOrder', + enablePromptCompletion: 'general.enablePromptCompletion', + enforcedAuthType: 'security.auth.enforcedType', + excludeTools: 'tools.exclude', + excludeMCPServers: 'mcp.excluded', + excludedProjectEnvVars: 'advanced.excludedEnvVars', + extensionManagement: 'experimental.extensionManagement', + extensions: 'extensions', + fileFiltering: 'context.fileFiltering', + folderTrustFeature: 'security.folderTrust.featureEnabled', + folderTrust: 'security.folderTrust.enabled', + hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', hideWindowTitle: 'ui.hideWindowTitle', + showStatusInTitle: 'ui.showStatusInTitle', hideTips: 'ui.hideTips', hideBanner: 'ui.hideBanner', hideFooter: 'ui.hideFooter', + hideCWD: 'ui.footer.hideCWD', + hideSandboxStatus: 'ui.footer.hideSandboxStatus', + hideModelInfo: 'ui.footer.hideModelInfo', + hideContextSummary: 'ui.hideContextSummary', showMemoryUsage: 'ui.showMemoryUsage', showLineNumbers: 'ui.showLineNumbers', - accessibility: 'ui.accessibility', + showCitations: 'ui.showCitations', ideMode: 'ide.enabled', - hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', - usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', - telemetry: 'telemetry', - model: 'model.name', - maxSessionTurns: 'model.maxSessionTurns', - summarizeToolOutput: 'model.summarizeToolOutput', - chatCompression: 'model.chatCompression', - skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', - contextFileName: 'context.fileName', - memoryImportFormat: 'context.importFormat', - memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', includeDirectories: 'context.includeDirectories', loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', - fileFiltering: 'context.fileFiltering', + maxSessionTurns: 'model.maxSessionTurns', + mcpServers: 'mcpServers', + mcpServerCommand: 'mcp.serverCommand', + memoryImportFormat: 'context.importFormat', + memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', + model: 'model.name', + preferredEditor: 'general.preferredEditor', sandbox: 'tools.sandbox', - shouldUseNodePtyShell: 'tools.usePty', - allowedTools: 'tools.allowed', - coreTools: 'tools.core', - excludeTools: 'tools.exclude', + selectedAuthType: 'security.auth.selectedType', + shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell', + shellPager: 'tools.shell.pager', + shellShowColor: 'tools.shell.showColor', + skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', + summarizeToolOutput: 'model.summarizeToolOutput', + telemetry: 'telemetry', + theme: 'ui.theme', toolDiscoveryCommand: 'tools.discoveryCommand', toolCallCommand: 'tools.callCommand', - mcpServerCommand: 'mcp.serverCommand', - allowMCPServers: 'mcp.allowed', - excludeMCPServers: 'mcp.excluded', - folderTrustFeature: 'security.folderTrust.featureEnabled', - folderTrust: 'security.folderTrust.enabled', - selectedAuthType: 'security.auth.selectedType', + usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', useExternalAuth: 'security.auth.useExternal', - autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', - dnsResolutionOrder: 'advanced.dnsResolutionOrder', - excludedProjectEnvVars: 'advanced.excludedEnvVars', - bugCommand: 'advanced.bugCommand', + useRipgrep: 'tools.useRipgrep', + vimMode: 'general.vimMode', + + enableWelcomeBack: 'ui.enableWelcomeBack', + approvalMode: 'tools.approvalMode', + sessionTokenLimit: 'model.sessionTokenLimit', + contentGenerator: 'model.generationConfig', + skipLoopDetection: 'model.skipLoopDetection', + enableOpenAILogging: 'model.enableOpenAILogging', + tavilyApiKey: 'advanced.tavilyApiKey', + vlmSwitchMode: 'experimental.vlmSwitchMode', + visionModelPreview: 'experimental.visionModelPreview', }; export function getSystemSettingsPath(): string { @@ -131,7 +185,9 @@ export interface SettingsError { export interface SettingsFile { settings: Settings; + originalSettings: Settings; path: string; + rawJson?: string; } function setNestedProperty( @@ -159,8 +215,27 @@ function setNestedProperty( current[lastKey] = value; } -function needsMigration(settings: Record): boolean { - return !('general' in settings); +export function needsMigration(settings: Record): boolean { + // A file needs migration if it contains any top-level key that is moved to a + // nested location in V2. + const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { + if (v1Key === v2Path || !(v1Key in settings)) { + return false; + } + // If a key exists that is both a V1 key and a V2 container (like 'model'), + // we need to check the type. If it's an object, it's a V2 container and not + // a V1 key that needs migration. + if ( + KNOWN_V2_CONTAINERS.has(v1Key) && + typeof settings[v1Key] === 'object' && + settings[v1Key] !== null + ) { + return false; + } + return true; + }); + + return hasV1Keys; } function migrateSettingsToV2( @@ -188,7 +263,28 @@ function migrateSettingsToV2( // Carry over any unrecognized keys for (const remainingKey of flatKeys) { - v2Settings[remainingKey] = flatSettings[remainingKey]; + const existingValue = v2Settings[remainingKey]; + const newValue = flatSettings[remainingKey]; + + if ( + typeof existingValue === 'object' && + existingValue !== null && + !Array.isArray(existingValue) && + typeof newValue === 'object' && + newValue !== null && + !Array.isArray(newValue) + ) { + const pathAwareGetStrategy = (path: string[]) => + getMergeStrategyForPath([remainingKey, ...path]); + v2Settings[remainingKey] = customDeepMerge( + pathAwareGetStrategy, + {}, + newValue as MergeableObject, + existingValue as MergeableObject, + ); + } else { + v2Settings[remainingKey] = newValue; + } } return v2Settings; @@ -271,173 +367,20 @@ function mergeSettings( ): Settings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); - // folderTrust is not supported at workspace level. - const { security, ...restOfWorkspace } = safeWorkspace; - const safeWorkspaceWithoutFolderTrust = security - ? { - ...restOfWorkspace, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - security: (({ folderTrust, ...rest }) => rest)(security), - } - : { - ...restOfWorkspace, - security: {}, - }; - // Settings are merged with the following precedence (last one wins for // single values): // 1. System Defaults // 2. User Settings // 3. Workspace Settings // 4. System Settings (as overrides) - // - // For properties that are arrays (e.g., includeDirectories), the arrays - // are concatenated. For objects (e.g., customThemes), they are merged. - return { - ...systemDefaults, - ...user, - ...safeWorkspaceWithoutFolderTrust, - ...system, - general: { - ...(systemDefaults.general || {}), - ...(user.general || {}), - ...(safeWorkspaceWithoutFolderTrust.general || {}), - ...(system.general || {}), - }, - ui: { - ...(systemDefaults.ui || {}), - ...(user.ui || {}), - ...(safeWorkspaceWithoutFolderTrust.ui || {}), - ...(system.ui || {}), - customThemes: { - ...(systemDefaults.ui?.customThemes || {}), - ...(user.ui?.customThemes || {}), - ...(safeWorkspaceWithoutFolderTrust.ui?.customThemes || {}), - ...(system.ui?.customThemes || {}), - }, - }, - ide: { - ...(systemDefaults.ide || {}), - ...(user.ide || {}), - ...(safeWorkspaceWithoutFolderTrust.ide || {}), - ...(system.ide || {}), - }, - privacy: { - ...(systemDefaults.privacy || {}), - ...(user.privacy || {}), - ...(safeWorkspaceWithoutFolderTrust.privacy || {}), - ...(system.privacy || {}), - }, - telemetry: { - ...(systemDefaults.telemetry || {}), - ...(user.telemetry || {}), - ...(safeWorkspaceWithoutFolderTrust.telemetry || {}), - ...(system.telemetry || {}), - }, - security: { - ...(systemDefaults.security || {}), - ...(user.security || {}), - ...(safeWorkspaceWithoutFolderTrust.security || {}), - ...(system.security || {}), - }, - mcp: { - ...(systemDefaults.mcp || {}), - ...(user.mcp || {}), - ...(safeWorkspaceWithoutFolderTrust.mcp || {}), - ...(system.mcp || {}), - }, - mcpServers: { - ...(systemDefaults.mcpServers || {}), - ...(user.mcpServers || {}), - ...(safeWorkspaceWithoutFolderTrust.mcpServers || {}), - ...(system.mcpServers || {}), - }, - tools: { - ...(systemDefaults.tools || {}), - ...(user.tools || {}), - ...(safeWorkspaceWithoutFolderTrust.tools || {}), - ...(system.tools || {}), - }, - context: { - ...(systemDefaults.context || {}), - ...(user.context || {}), - ...(safeWorkspaceWithoutFolderTrust.context || {}), - ...(system.context || {}), - includeDirectories: [ - ...(systemDefaults.context?.includeDirectories || []), - ...(user.context?.includeDirectories || []), - ...(safeWorkspaceWithoutFolderTrust.context?.includeDirectories || []), - ...(system.context?.includeDirectories || []), - ], - }, - model: { - ...(systemDefaults.model || {}), - ...(user.model || {}), - ...(safeWorkspaceWithoutFolderTrust.model || {}), - ...(system.model || {}), - chatCompression: { - ...(systemDefaults.model?.chatCompression || {}), - ...(user.model?.chatCompression || {}), - ...(safeWorkspaceWithoutFolderTrust.model?.chatCompression || {}), - ...(system.model?.chatCompression || {}), - }, - }, - advanced: { - ...(systemDefaults.advanced || {}), - ...(user.advanced || {}), - ...(safeWorkspaceWithoutFolderTrust.advanced || {}), - ...(system.advanced || {}), - excludedEnvVars: [ - ...new Set([ - ...(systemDefaults.advanced?.excludedEnvVars || []), - ...(user.advanced?.excludedEnvVars || []), - ...(safeWorkspaceWithoutFolderTrust.advanced?.excludedEnvVars || []), - ...(system.advanced?.excludedEnvVars || []), - ]), - ], - }, - experimental: { - ...(systemDefaults.experimental || {}), - ...(user.experimental || {}), - ...(safeWorkspaceWithoutFolderTrust.experimental || {}), - ...(system.experimental || {}), - }, - contentGenerator: { - ...(systemDefaults.contentGenerator || {}), - ...(user.contentGenerator || {}), - ...(safeWorkspaceWithoutFolderTrust.contentGenerator || {}), - ...(system.contentGenerator || {}), - }, - systemPromptMappings: { - ...(systemDefaults.systemPromptMappings || {}), - ...(user.systemPromptMappings || {}), - ...(safeWorkspaceWithoutFolderTrust.systemPromptMappings || {}), - ...(system.systemPromptMappings || {}), - }, - extensions: { - ...(systemDefaults.extensions || {}), - ...(user.extensions || {}), - ...(safeWorkspaceWithoutFolderTrust.extensions || {}), - ...(system.extensions || {}), - disabled: [ - ...new Set([ - ...(systemDefaults.extensions?.disabled || []), - ...(user.extensions?.disabled || []), - ...(safeWorkspaceWithoutFolderTrust.extensions?.disabled || []), - ...(system.extensions?.disabled || []), - ]), - ], - workspacesWithMigrationNudge: [ - ...new Set([ - ...(systemDefaults.extensions?.workspacesWithMigrationNudge || []), - ...(user.extensions?.workspacesWithMigrationNudge || []), - ...(safeWorkspaceWithoutFolderTrust.extensions - ?.workspacesWithMigrationNudge || []), - ...(system.extensions?.workspacesWithMigrationNudge || []), - ]), - ], - }, - }; + return customDeepMerge( + getMergeStrategyForPath, + {}, // Start with an empty object + systemDefaults, + user, + safeWorkspace, + system, + ) as Settings; } export class LoadedSettings { @@ -446,7 +389,6 @@ export class LoadedSettings { systemDefaults: SettingsFile, user: SettingsFile, workspace: SettingsFile, - errors: SettingsError[], isTrusted: boolean, migratedInMemorScopes: Set, ) { @@ -454,7 +396,6 @@ export class LoadedSettings { this.systemDefaults = systemDefaults; this.user = user; this.workspace = workspace; - this.errors = errors; this.isTrusted = isTrusted; this.migratedInMemorScopes = migratedInMemorScopes; this._merged = this.computeMergedSettings(); @@ -464,7 +405,6 @@ export class LoadedSettings { readonly systemDefaults: SettingsFile; readonly user: SettingsFile; readonly workspace: SettingsFile; - readonly errors: SettingsError[]; readonly isTrusted: boolean; readonly migratedInMemorScopes: Set; @@ -502,58 +442,17 @@ export class LoadedSettings { setValue(scope: SettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); setNestedProperty(settingsFile.settings, key, value); + setNestedProperty(settingsFile.originalSettings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); } } -function resolveEnvVarsInString(value: string): string { - const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} - return value.replace(envVarRegex, (match, varName1, varName2) => { - 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') { - return resolveEnvVarsInString(obj) as unknown as T; - } - - if (Array.isArray(obj)) { - 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; -} - 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'); + // prefer gemini-specific .env under QWEN_DIR + const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env'); if (fs.existsSync(geminiEnvPath)) { return geminiEnvPath; } @@ -564,7 +463,7 @@ function findEnvFile(startDir: string): string | null { 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'); + const homeGeminiEnvPath = path.join(homedir(), QWEN_DIR, '.env'); if (fs.existsSync(homeGeminiEnvPath)) { return homeGeminiEnvPath; } @@ -600,36 +499,18 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void { } } -export function loadEnvironment(settings?: Settings): void { +export function loadEnvironment(settings: Settings): void { const envFilePath = findEnvFile(process.cwd()); + if (!isWorkspaceTrusted(settings).isTrusted) { + return; + } + // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath); } - // If no settings provided, try to load workspace settings for exclusions - let resolvedSettings = settings; - if (!resolvedSettings) { - const workspaceSettingsPath = new Storage( - process.cwd(), - ).getWorkspaceSettingsPath(); - try { - if (fs.existsSync(workspaceSettingsPath)) { - const workspaceContent = fs.readFileSync( - workspaceSettingsPath, - 'utf-8', - ); - const parsedWorkspaceSettings = JSON.parse( - stripJsonComments(workspaceContent), - ) as Settings; - resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); - } - } catch (_e) { - // Ignore errors loading workspace settings - } - } - if (envFilePath) { // Manually parse and load environment variables to handle exclusions correctly. // This avoids modifying environment variables that were already set from the shell. @@ -638,9 +519,8 @@ export function loadEnvironment(settings?: Settings): void { const parsedEnv = dotenv.parse(envFileContent); const excludedVars = - resolvedSettings?.advanced?.excludedEnvVars || - DEFAULT_EXCLUDED_ENV_VARS; - const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); + settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(QWEN_DIR); for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { @@ -665,7 +545,9 @@ export function loadEnvironment(settings?: Settings): void { * Loads settings from user and workspace directories. * Project settings override user settings. */ -export function loadSettings(workspaceDir: string): LoadedSettings { +export function loadSettings( + workspaceDir: string = process.cwd(), +): LoadedSettings { let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; @@ -694,7 +576,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings { workspaceDir, ).getWorkspaceSettingsPath(); - const loadAndMigrate = (filePath: string, scope: SettingScope): Settings => { + const loadAndMigrate = ( + filePath: string, + scope: SettingScope, + ): { settings: Settings; rawJson?: string } => { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); @@ -709,7 +594,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings { message: 'Settings file is not a valid JSON object.', path: filePath, }); - return {}; + return { settings: {} }; } let settingsObject = rawSettings as Record; @@ -737,7 +622,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings { settingsObject = migratedSettings; } } - return settingsObject as Settings; + return { settings: settingsObject as Settings, rawJson: content }; } } catch (error: unknown) { settingsErrors.push({ @@ -745,23 +630,40 @@ export function loadSettings(workspaceDir: string): LoadedSettings { path: filePath, }); } - return {}; + return { settings: {} }; }; - systemSettings = loadAndMigrate(systemSettingsPath, SettingScope.System); - systemDefaultSettings = loadAndMigrate( + const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System); + const systemDefaultsResult = loadAndMigrate( systemDefaultsPath, SettingScope.SystemDefaults, ); - userSettings = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); + const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); + let workspaceResult: { settings: Settings; rawJson?: string } = { + settings: {} as Settings, + rawJson: undefined, + }; if (realWorkspaceDir !== realHomeDir) { - workspaceSettings = loadAndMigrate( + workspaceResult = loadAndMigrate( workspaceSettingsPath, SettingScope.Workspace, ); } + 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; @@ -775,9 +677,14 @@ export function loadSettings(workspaceDir: string): LoadedSettings { } // For the initial trust check, we can only use user and system settings. - const initialTrustCheckSettings = mergeWith({}, systemSettings, userSettings); + const initialTrustCheckSettings = customDeepMerge( + getMergeStrategyForPath, + {}, + systemSettings, + userSettings, + ); const isTrusted = - isWorkspaceTrusted(initialTrustCheckSettings as Settings) ?? true; + isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( @@ -792,35 +699,70 @@ export function loadSettings(workspaceDir: string): LoadedSettings { // the settings to avoid a cycle loadEnvironment(tempMergedSettings); - // Now that the environment is loaded, resolve variables in the settings. - systemSettings = resolveEnvVarsInObject(systemSettings); - userSettings = resolveEnvVarsInObject(userSettings); - workspaceSettings = resolveEnvVarsInObject(workspaceSettings); - // Create LoadedSettings first - const loadedSettings = new LoadedSettings( + + if (settingsErrors.length > 0) { + const errorMessages = settingsErrors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, + ); + } + + return new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, + originalSettings: systemOriginalSettings, + rawJson: systemResult.rawJson, }, { path: systemDefaultsPath, settings: systemDefaultSettings, + originalSettings: systemDefaultsOriginalSettings, + rawJson: systemDefaultsResult.rawJson, }, { path: USER_SETTINGS_PATH, settings: userSettings, + originalSettings: userOriginalSettings, + rawJson: userResult.rawJson, }, { path: workspaceSettingsPath, settings: workspaceSettings, + originalSettings: workspaceOriginalSettings, + rawJson: workspaceResult.rawJson, }, - settingsErrors, isTrusted, migratedInMemorScopes, ); +} - return loadedSettings; +export function migrateDeprecatedSettings( + loadedSettings: LoadedSettings, + workspaceDir: string = process.cwd(), +): void { + const processScope = (scope: SettingScope) => { + const settings = loadedSettings.forScope(scope).settings; + if (settings.extensions?.disabled) { + console.log( + `Migrating deprecated extensions.disabled settings from ${scope} settings...`, + ); + for (const extension of settings.extensions.disabled ?? []) { + disableExtension(extension, scope, workspaceDir); + } + + const newExtensionsValue = { ...settings.extensions }; + newExtensionsValue.disabled = undefined; + + loadedSettings.setValue(scope, 'extensions', newExtensionsValue); + } + }; + + processScope(SettingScope.User); + processScope(SettingScope.Workspace); } export function saveSettings(settingsFile: SettingsFile): void { @@ -831,17 +773,17 @@ export function saveSettings(settingsFile: SettingsFile): void { fs.mkdirSync(dirPath, { recursive: true }); } - let settingsToSave = settingsFile.settings; + let settingsToSave = settingsFile.originalSettings; if (!MIGRATE_V2_OVERWRITE) { settingsToSave = migrateSettingsToV1( settingsToSave as Record, ) as Settings; } - fs.writeFileSync( + // Use the format-preserving update function + updateSettingsFilePreservingFormat( settingsFile.path, - JSON.stringify(settingsToSave, null, 2), - 'utf-8', + settingsToSave as Record, ); } catch (error) { console.error('Error saving user settings file:', error); diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index e78f35c8..407d5441 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -5,13 +5,17 @@ */ import { describe, it, expect } from 'vitest'; -import type { Settings } from './settingsSchema.js'; -import { SETTINGS_SCHEMA } from './settingsSchema.js'; +import { + getSettingsSchema, + type SettingDefinition, + type Settings, + type SettingsSchema, +} from './settingsSchema.js'; describe('SettingsSchema', () => { - describe('SETTINGS_SCHEMA', () => { + describe('getSettingsSchema', () => { it('should contain all expected top-level settings', () => { - const expectedSettings = [ + const expectedSettings: Array = [ 'mcpServers', 'general', 'ui', @@ -24,18 +28,16 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'enableWelcomeBack', + 'experimental', ]; expectedSettings.forEach((setting) => { - expect( - SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA], - ).toBeDefined(); + expect(getSettingsSchema()[setting as keyof Settings]).toBeDefined(); }); }); it('should have correct structure for each setting', () => { - Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => { + Object.entries(getSettingsSchema()).forEach(([_key, definition]) => { expect(definition).toHaveProperty('type'); expect(definition).toHaveProperty('label'); expect(definition).toHaveProperty('category'); @@ -49,7 +51,7 @@ describe('SettingsSchema', () => { }); it('should have correct nested setting structure', () => { - const nestedSettings = [ + const nestedSettings: Array = [ 'general', 'ui', 'ide', @@ -63,11 +65,9 @@ describe('SettingsSchema', () => { ]; nestedSettings.forEach((setting) => { - const definition = SETTINGS_SCHEMA[ - setting as keyof typeof SETTINGS_SCHEMA - ] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & { - properties: unknown; - }; + const definition = getSettingsSchema()[ + setting as keyof Settings + ] as SettingDefinition; expect(definition.type).toBe('object'); expect(definition.properties).toBeDefined(); expect(typeof definition.properties).toBe('object'); @@ -76,35 +76,36 @@ describe('SettingsSchema', () => { it('should have accessibility nested properties', () => { expect( - SETTINGS_SCHEMA.ui?.properties?.accessibility?.properties, + getSettingsSchema().ui?.properties?.accessibility?.properties, ).toBeDefined(); expect( - SETTINGS_SCHEMA.ui?.properties?.accessibility.properties + getSettingsSchema().ui?.properties?.accessibility.properties ?.disableLoadingPhrases.type, ).toBe('boolean'); }); it('should have checkpointing nested properties', () => { expect( - SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled, + getSettingsSchema().general?.properties?.checkpointing.properties + ?.enabled, ).toBeDefined(); expect( - SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled - .type, + getSettingsSchema().general?.properties?.checkpointing.properties + ?.enabled.type, ).toBe('boolean'); }); it('should have fileFiltering nested properties', () => { expect( - SETTINGS_SCHEMA.context.properties.fileFiltering.properties + getSettingsSchema().context.properties.fileFiltering.properties ?.respectGitIgnore, ).toBeDefined(); expect( - SETTINGS_SCHEMA.context.properties.fileFiltering.properties - ?.respectGeminiIgnore, + getSettingsSchema().context.properties.fileFiltering.properties + ?.respectQwenIgnore, ).toBeDefined(); expect( - SETTINGS_SCHEMA.context.properties.fileFiltering.properties + getSettingsSchema().context.properties.fileFiltering.properties ?.enableRecursiveFileSearch, ).toBeDefined(); }); @@ -113,7 +114,7 @@ describe('SettingsSchema', () => { const categories = new Set(); // Collect categories from top-level settings - Object.values(SETTINGS_SCHEMA).forEach((definition) => { + Object.values(getSettingsSchema()).forEach((definition) => { categories.add(definition.category); // Also collect from nested properties const defWithProps = definition as typeof definition & { @@ -138,74 +139,80 @@ describe('SettingsSchema', () => { }); it('should have consistent default values for boolean settings', () => { - const checkBooleanDefaults = (schema: Record) => { - Object.entries(schema).forEach( - ([_key, definition]: [string, unknown]) => { - const def = definition as { - type?: string; - default?: unknown; - properties?: Record; - }; - 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); - } - }, - ); + const checkBooleanDefaults = (schema: SettingsSchema) => { + Object.entries(schema).forEach(([, definition]) => { + const def = definition as SettingDefinition; + 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(SETTINGS_SCHEMA as Record); + checkBooleanDefaults(getSettingsSchema() as SettingsSchema); }); it('should have showInDialog property configured', () => { // Check that user-facing settings are marked for dialog display - expect(SETTINGS_SCHEMA.ui.properties.showMemoryUsage.showInDialog).toBe( - true, - ); - expect(SETTINGS_SCHEMA.general.properties.vimMode.showInDialog).toBe( - true, - ); - expect(SETTINGS_SCHEMA.ide.properties.enabled.showInDialog).toBe(true); expect( - SETTINGS_SCHEMA.general.properties.disableAutoUpdate.showInDialog, + getSettingsSchema().ui.properties.showMemoryUsage.showInDialog, ).toBe(true); - expect(SETTINGS_SCHEMA.ui.properties.hideWindowTitle.showInDialog).toBe( + expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe( + true, + ); + expect(getSettingsSchema().ide.properties.enabled.showInDialog).toBe( true, ); - expect(SETTINGS_SCHEMA.ui.properties.hideTips.showInDialog).toBe(true); - expect(SETTINGS_SCHEMA.ui.properties.hideBanner.showInDialog).toBe(true); expect( - SETTINGS_SCHEMA.privacy.properties.usageStatisticsEnabled.showInDialog, + getSettingsSchema().general.properties.disableAutoUpdate.showInDialog, + ).toBe(true); + expect( + getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, + ).toBe(true); + expect(getSettingsSchema().ui.properties.hideTips.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(SETTINGS_SCHEMA.security.properties.auth.showInDialog).toBe(false); - expect(SETTINGS_SCHEMA.tools.properties.core.showInDialog).toBe(false); - expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false); - expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false); + 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(SETTINGS_SCHEMA.ui.properties.theme.showInDialog).toBe(false); // Changed to false - expect(SETTINGS_SCHEMA.ui.properties.customThemes.showInDialog).toBe( + expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false + expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe( false, ); // Managed via theme editor expect( - SETTINGS_SCHEMA.general.properties.checkpointing.showInDialog, + getSettingsSchema().general.properties.checkpointing.showInDialog, ).toBe(false); // Experimental feature - expect(SETTINGS_SCHEMA.ui.properties.accessibility.showInDialog).toBe( + expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe( false, ); // Changed to false expect( - SETTINGS_SCHEMA.context.properties.fileFiltering.showInDialog, + getSettingsSchema().context.properties.fileFiltering.showInDialog, ).toBe(false); // Changed to false expect( - SETTINGS_SCHEMA.general.properties.preferredEditor.showInDialog, + getSettingsSchema().general.properties.preferredEditor.showInDialog, ).toBe(false); // Changed to false expect( - SETTINGS_SCHEMA.advanced.properties.autoConfigureMemory.showInDialog, + getSettingsSchema().advanced.properties.autoConfigureMemory + .showInDialog, ).toBe(false); }); @@ -229,80 +236,84 @@ describe('SettingsSchema', () => { it('should have includeDirectories setting in schema', () => { expect( - SETTINGS_SCHEMA.context?.properties.includeDirectories, + getSettingsSchema().context?.properties.includeDirectories, ).toBeDefined(); - expect(SETTINGS_SCHEMA.context?.properties.includeDirectories.type).toBe( - 'array', - ); expect( - SETTINGS_SCHEMA.context?.properties.includeDirectories.category, + getSettingsSchema().context?.properties.includeDirectories.type, + ).toBe('array'); + expect( + getSettingsSchema().context?.properties.includeDirectories.category, ).toBe('Context'); expect( - SETTINGS_SCHEMA.context?.properties.includeDirectories.default, + getSettingsSchema().context?.properties.includeDirectories.default, ).toEqual([]); }); it('should have loadMemoryFromIncludeDirectories setting in schema', () => { expect( - SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories, + getSettingsSchema().context?.properties + .loadMemoryFromIncludeDirectories, ).toBeDefined(); expect( - SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .type, ).toBe('boolean'); expect( - SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .category, ).toBe('Context'); expect( - SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories + getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories .default, ).toBe(false); }); it('should have folderTrustFeature setting in schema', () => { expect( - SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled, + getSettingsSchema().security.properties.folderTrust.properties.enabled, ).toBeDefined(); expect( - SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled.type, + getSettingsSchema().security.properties.folderTrust.properties.enabled + .type, ).toBe('boolean'); expect( - SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + getSettingsSchema().security.properties.folderTrust.properties.enabled .category, ).toBe('Security'); expect( - SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + getSettingsSchema().security.properties.folderTrust.properties.enabled .default, ).toBe(false); expect( - SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled + getSettingsSchema().security.properties.folderTrust.properties.enabled .showInDialog, ).toBe(true); }); it('should have debugKeystrokeLogging setting in schema', () => { expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging, + getSettingsSchema().general.properties.debugKeystrokeLogging, ).toBeDefined(); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.type, + getSettingsSchema().general.properties.debugKeystrokeLogging.type, ).toBe('boolean'); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.category, + getSettingsSchema().general.properties.debugKeystrokeLogging.category, ).toBe('General'); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.default, + getSettingsSchema().general.properties.debugKeystrokeLogging.default, ).toBe(false); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging + getSettingsSchema().general.properties.debugKeystrokeLogging .requiresRestart, ).toBe(false); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.showInDialog, + getSettingsSchema().general.properties.debugKeystrokeLogging + .showInDialog, ).toBe(true); expect( - SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.description, + getSettingsSchema().general.properties.debugKeystrokeLogging + .description, ).toBe('Enable debug logging of keystrokes to the console.'); }); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 9fb29a99..34ebe4b0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -11,20 +11,69 @@ import type { AuthType, ChatCompressionSettings, } from '@qwen-code/qwen-code-core'; +import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '@qwen-code/qwen-code-core'; import type { CustomTheme } from '../ui/themes/theme.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; +} + +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: 'boolean' | 'string' | 'number' | 'array' | 'object'; + type: SettingsType; label: string; category: string; requiresRestart: boolean; - default: boolean | string | number | string[] | object | undefined; + default: SettingsValue; description?: string; parentKey?: string; childKey?: string; key?: string; properties?: SettingsSchema; showInDialog?: boolean; + mergeStrategy?: MergeStrategy; + /** Enum type options */ + options?: readonly SettingEnumOption[]; } export interface SettingsSchema { @@ -39,7 +88,7 @@ export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; * 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. */ -export const SETTINGS_SCHEMA = { +const SETTINGS_SCHEMA = { // Maintained for compatibility/criticality mcpServers: { type: 'object', @@ -49,6 +98,7 @@ export const SETTINGS_SCHEMA = { default: {} as Record, description: 'Configuration for MCP servers.', showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, general: { @@ -137,6 +187,30 @@ export const SETTINGS_SCHEMA = { }, }, }, + 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.', + showInDialog: true, + options: [ + { value: 'text', label: 'Text' }, + { value: 'json', label: 'JSON' }, + ], + }, + }, + }, ui: { type: 'object', @@ -174,6 +248,16 @@ export const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, + showStatusInTitle: { + type: 'boolean', + label: 'Show Status in Title', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show Gemini CLI status and thoughts in the terminal window title', + showInDialog: true, + }, hideTips: { type: 'boolean', label: 'Hide Tips', @@ -192,6 +276,55 @@ export const SETTINGS_SCHEMA = { 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: { + hideCWD: { + type: 'boolean', + label: 'Hide CWD', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Hide the current working directory path 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, + }, + }, + }, hideFooter: { type: 'boolean', label: 'Hide Footer', @@ -219,6 +352,34 @@ export const SETTINGS_SCHEMA = { 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, + }, + customWittyPhrases: { + type: 'array', + label: 'Custom Witty Phrases', + category: 'UI', + requiresRestart: false, + default: [] as string[], + description: 'Custom witty phrases to display during loading.', + showInDialog: false, + }, + enableWelcomeBack: { + type: 'boolean', + label: 'Enable Welcome Back', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Show welcome back dialog when returning to a project with conversation history.', + showInDialog: true, + }, accessibility: { type: 'object', label: 'Accessibility', @@ -361,15 +522,86 @@ export const SETTINGS_SCHEMA = { description: 'Chat compression settings.', showInDialog: false, }, + sessionTokenLimit: { + type: 'number', + label: 'Session Token Limit', + category: 'Model', + requiresRestart: false, + default: undefined as number | undefined, + description: 'The maximum number of tokens allowed in a session.', + showInDialog: false, + }, skipNextSpeakerCheck: { type: 'boolean', label: 'Skip Next Speaker Check', category: 'Model', requiresRestart: false, - default: false, + default: true, description: 'Skip the next speaker check.', showInDialog: true, }, + skipLoopDetection: { + type: 'boolean', + label: 'Skip Loop Detection', + category: 'Model', + requiresRestart: false, + default: false, + description: 'Disable all loop detection checks (streaming and LLM).', + showInDialog: true, + }, + enableOpenAILogging: { + type: 'boolean', + label: 'Enable OpenAI Logging', + category: 'Model', + requiresRestart: false, + default: false, + description: 'Enable OpenAI logging.', + showInDialog: true, + }, + generationConfig: { + type: 'object', + label: 'Generation Configuration', + category: 'Model', + requiresRestart: false, + default: undefined as Record | undefined, + description: 'Generation configuration settings.', + showInDialog: false, + properties: { + timeout: { + type: 'number', + label: 'Timeout', + category: 'Generation Configuration', + requiresRestart: false, + default: undefined as number | undefined, + description: 'Request timeout in milliseconds.', + parentKey: 'generationConfig', + childKey: 'timeout', + showInDialog: true, + }, + maxRetries: { + type: 'number', + label: 'Max Retries', + category: 'Generation Configuration', + requiresRestart: false, + default: undefined as number | undefined, + description: 'Maximum number of retries for failed requests.', + parentKey: 'generationConfig', + childKey: 'maxRetries', + showInDialog: true, + }, + disableCacheControl: { + type: 'boolean', + label: 'Disable Cache Control', + category: 'Generation Configuration', + requiresRestart: false, + default: false, + description: 'Disable cache control for DashScope providers.', + parentKey: 'generationConfig', + childKey: 'disableCacheControl', + showInDialog: true, + }, + }, + }, }, }, @@ -418,6 +650,7 @@ export const SETTINGS_SCHEMA = { description: 'Additional directories to include in the workspace context. Missing directories will be skipped with a warning.', showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, loadMemoryFromIncludeDirectories: { type: 'boolean', @@ -446,7 +679,7 @@ export const SETTINGS_SCHEMA = { description: 'Respect .gitignore files when searching', showInDialog: true, }, - respectGeminiIgnore: { + respectQwenIgnore: { type: 'boolean', label: 'Respect .qwenignore', category: 'Context', @@ -497,14 +730,54 @@ export const SETTINGS_SCHEMA = { 'Sandbox execution environment (can be a boolean or a path string).', showInDialog: false, }, - usePty: { - type: 'boolean', - label: 'Use node-pty for Shell Execution', + shell: { + type: 'object', + label: 'Shell', category: 'Tools', - requiresRestart: true, + requiresRestart: false, + default: {}, + description: 'Settings for shell execution.', + showInDialog: false, + properties: { + enableInteractiveShell: { + type: 'boolean', + label: 'Enable Interactive Shell', + category: 'Tools', + requiresRestart: true, + default: false, + description: + '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, + }, + }, + }, + autoAccept: { + type: 'boolean', + label: 'Auto Accept', + category: 'Tools', + requiresRestart: false, default: false, description: - 'Use node-pty for shell command execution. Fallback to child_process still applies.', + 'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).', showInDialog: true, }, core: { @@ -534,6 +807,17 @@ export const SETTINGS_SCHEMA = { default: undefined as string[] | undefined, description: 'Tool names to exclude from discovery.', showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + approvalMode: { + type: 'string', + label: 'Default Approval Mode', + category: 'Tools', + requiresRestart: false, + default: 'default', + description: + 'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.', + showInDialog: true, }, discoveryCommand: { type: 'string', @@ -558,11 +842,39 @@ export const SETTINGS_SCHEMA = { label: 'Use Ripgrep', category: 'Tools', requiresRestart: false, - default: false, + default: true, description: 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', showInDialog: true, }, + enableToolOutputTruncation: { + type: 'boolean', + label: 'Enable Tool Output Truncation', + category: 'General', + requiresRestart: true, + default: true, + description: 'Enable truncation of large tool outputs.', + showInDialog: true, + }, + truncateToolOutputThreshold: { + type: 'number', + label: 'Tool Output Truncation Threshold', + category: 'General', + requiresRestart: true, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + description: + 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', + showInDialog: true, + }, + truncateToolOutputLines: { + type: 'number', + label: 'Tool Output Truncation Lines', + category: 'General', + requiresRestart: true, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + description: 'The number of lines to keep when truncating tool output.', + showInDialog: true, + }, }, }, @@ -590,7 +902,7 @@ export const SETTINGS_SCHEMA = { category: 'MCP', requiresRestart: true, default: undefined as string[] | undefined, - description: 'A whitelist of MCP servers to allow.', + description: 'A list of MCP servers to allow.', showInDialog: false, }, excluded: { @@ -599,12 +911,20 @@ export const SETTINGS_SCHEMA = { category: 'MCP', requiresRestart: true, default: undefined as string[] | undefined, - description: 'A blacklist of MCP servers to exclude.', + description: 'A list of MCP servers to exclude.', showInDialog: false, }, }, }, - + useSmartEdit: { + type: 'boolean', + label: 'Use Smart Edit', + category: 'Advanced', + requiresRestart: false, + default: false, + description: 'Enable the smart-edit tool instead of the replace tool.', + showInDialog: false, + }, security: { type: 'object', label: 'Security', @@ -623,20 +943,11 @@ export const SETTINGS_SCHEMA = { description: 'Settings for folder trust.', showInDialog: false, properties: { - featureEnabled: { - type: 'boolean', - label: 'Folder Trust Feature', - category: 'Security', - requiresRestart: false, - default: false, - description: 'Enable folder trust feature for enhanced security.', - showInDialog: true, - }, enabled: { type: 'boolean', label: 'Folder Trust', category: 'Security', - requiresRestart: false, + requiresRestart: true, default: false, description: 'Setting to track whether Folder trust is enabled.', showInDialog: true, @@ -661,6 +972,16 @@ export const SETTINGS_SCHEMA = { 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', @@ -710,6 +1031,7 @@ export const SETTINGS_SCHEMA = { default: ['DEBUG', 'DEBUG_MODE'] as string[], description: 'Environment variables to exclude from project context.', showInDialog: false, + mergeStrategy: MergeStrategy.UNION, }, bugCommand: { type: 'object', @@ -720,6 +1042,16 @@ export const SETTINGS_SCHEMA = { description: 'Configuration for the bug report command.', showInDialog: false, }, + tavilyApiKey: { + type: 'string', + label: 'Tavily API Key', + category: 'Advanced', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'The API key for the Tavily API. Required to enable the web_search tool functionality.', + showInDialog: false, + }, }, }, @@ -737,7 +1069,7 @@ export const SETTINGS_SCHEMA = { label: 'Extension Management', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable extension management features.', showInDialog: false, }, @@ -781,6 +1113,7 @@ export const SETTINGS_SCHEMA = { default: [] as string[], description: 'List of disabled extensions.', showInDialog: false, + mergeStrategy: MergeStrategy.UNION, }, workspacesWithMigrationNudge: { type: 'array', @@ -791,135 +1124,34 @@ export const SETTINGS_SCHEMA = { description: 'List of workspaces for which the migration nudge has been shown.', showInDialog: false, + mergeStrategy: MergeStrategy.UNION, }, }, }, - contentGenerator: { - type: 'object', - label: 'Content Generator', - category: 'General', - requiresRestart: false, - default: undefined as Record | undefined, - description: 'Content generator settings.', - showInDialog: false, - properties: { - timeout: { - type: 'number', - label: 'Timeout', - category: 'Content Generator', - requiresRestart: false, - default: undefined as number | undefined, - description: 'Request timeout in milliseconds.', - parentKey: 'contentGenerator', - childKey: 'timeout', - showInDialog: true, - }, - maxRetries: { - type: 'number', - label: 'Max Retries', - category: 'Content Generator', - requiresRestart: false, - default: undefined as number | undefined, - description: 'Maximum number of retries for failed requests.', - parentKey: 'contentGenerator', - childKey: 'maxRetries', - showInDialog: true, - }, - disableCacheControl: { - type: 'boolean', - label: 'Disable Cache Control', - category: 'Content Generator', - requiresRestart: false, - default: false, - description: 'Disable cache control for DashScope providers.', - parentKey: 'contentGenerator', - childKey: 'disableCacheControl', - showInDialog: true, - }, - }, - }, - enableOpenAILogging: { - type: 'boolean', - label: 'Enable OpenAI Logging', - category: 'General', - requiresRestart: false, - default: false, - description: 'Enable OpenAI logging.', - showInDialog: true, - }, - sessionTokenLimit: { - type: 'number', - label: 'Session Token Limit', - category: 'General', - requiresRestart: false, - default: undefined as number | undefined, - description: 'The maximum number of tokens allowed in a session.', - showInDialog: false, - }, - systemPromptMappings: { - type: 'object', - label: 'System Prompt Mappings', - category: 'General', - requiresRestart: false, - default: undefined as Record | undefined, - description: 'Mappings of system prompts to model names.', - showInDialog: false, - }, - tavilyApiKey: { - type: 'string', - label: 'Tavily API Key', - category: 'General', - requiresRestart: false, - default: undefined as string | undefined, - description: 'The API key for the Tavily API.', - showInDialog: false, - }, - skipNextSpeakerCheck: { - type: 'boolean', - label: 'Skip Next Speaker Check', - category: 'General', - requiresRestart: false, - default: false, - description: 'Skip the next speaker check.', - showInDialog: true, - }, - skipLoopDetection: { - type: 'boolean', - label: 'Skip Loop Detection', - category: 'General', - requiresRestart: false, - default: false, - description: 'Disable all loop detection checks (streaming and LLM).', - showInDialog: true, - }, - approvalMode: { - type: 'string', - label: 'Default Approval Mode', - category: 'General', - requiresRestart: false, - default: 'default', - description: - 'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.', - showInDialog: true, - }, - enableWelcomeBack: { - type: 'boolean', - label: 'Enable Welcome Back', - category: 'UI', - requiresRestart: false, - default: true, - description: - 'Show welcome back dialog when returning to a project with conversation history.', - showInDialog: true, - }, -} as const; +} as const satisfies SettingsSchema; + +export type SettingsSchemaType = typeof SETTINGS_SCHEMA; + +export function getSettingsSchema(): SettingsSchemaType { + return SETTINGS_SCHEMA; +} type InferSettings = { -readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema } ? InferSettings - : T[K]['default'] extends boolean - ? boolean - : T[K]['default']; + : 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']; }; -export type Settings = InferSettings; +export type Settings = InferSettings; + +export interface FooterSettings { + hideCWD?: boolean; + hideSandboxStatus?: boolean; + hideModelInfo?: boolean; +} diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index b6583a83..9d06dcf3 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -4,17 +4,8 @@ * 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'), - }; -}); - +import { FatalConfigError, ideContextStore } from '@qwen-code/qwen-code-core'; import { describe, it, @@ -28,15 +19,23 @@ import { import * as fs from 'node:fs'; import stripJsonComments from 'strip-json-comments'; import * as path from 'node:path'; - import { loadTrustedFolders, - USER_TRUSTED_FOLDERS_PATH, + getTrustedFoldersPath, TrustLevel, isWorkspaceTrusted, + resetTrustedFoldersForTesting, } from './trustedFolders.js'; import type { Settings } from './settings.js'; +vi.mock('os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => '/mock/home/user'), + platform: vi.fn(() => 'linux'), + }; +}); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { @@ -47,7 +46,6 @@ vi.mock('fs', async (importOriginal) => { mkdirSync: vi.fn(), }; }); - vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); @@ -58,6 +56,7 @@ describe('Trusted Folders Loading', () => { let mockFsWriteFileSync: Mocked; beforeEach(() => { + resetTrustedFoldersForTesting(); vi.resetAllMocks(); mockFsExistsSync = vi.mocked(fs.existsSync); mockStripJsonComments = vi.mocked(stripJsonComments); @@ -80,8 +79,54 @@ describe('Trusted Folders Loading', () => { expect(errors).toEqual([]); }); + describe('isPathTrusted', () => { + function setup({ config = {} as Record } = {}) { + (mockFsExistsSync as Mock).mockImplementation( + (p) => p === getTrustedFoldersPath(), + ); + (fs.readFileSync as Mock).mockImplementation((p) => { + if (p === getTrustedFoldersPath()) return JSON.stringify(config); + return '{}'; + }); + + const folders = loadTrustedFolders(); + + return { folders }; + } + + it('provides a method to determine if a path is trusted', () => { + const { folders } = setup({ + config: { + './myfolder': TrustLevel.TRUST_FOLDER, + '/trustedparent/trustme': TrustLevel.TRUST_PARENT, + '/user/folder': TrustLevel.TRUST_FOLDER, + '/secret': TrustLevel.DO_NOT_TRUST, + '/secret/publickeys': TrustLevel.TRUST_FOLDER, + }, + }); + 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('myfolder/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( + undefined, + ); + expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe( + undefined, + ); + expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined); + }); + }); + it('should load user rules if only user file exists', () => { - const userPath = USER_TRUSTED_FOLDERS_PATH; + const userPath = getTrustedFoldersPath(); (mockFsExistsSync as Mock).mockImplementation((p) => p === userPath); const userContent = { '/user/folder': TrustLevel.TRUST_FOLDER, @@ -99,7 +144,7 @@ describe('Trusted Folders Loading', () => { }); it('should handle JSON parsing errors gracefully', () => { - const userPath = USER_TRUSTED_FOLDERS_PATH; + const userPath = getTrustedFoldersPath(); (mockFsExistsSync as Mock).mockImplementation((p) => p === userPath); (fs.readFileSync as Mock).mockImplementation((p) => { if (p === userPath) return 'invalid json'; @@ -113,6 +158,31 @@ describe('Trusted Folders Loading', () => { expect(errors[0].message).toContain('Unexpected token'); }); + it('should use GEMINI_CLI_TRUSTED_FOLDERS_PATH env var if set', () => { + const customPath = '/custom/path/to/trusted_folders.json'; + process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath; + + (mockFsExistsSync as Mock).mockImplementation((p) => p === customPath); + const userContent = { + '/user/folder/from/env': TrustLevel.TRUST_FOLDER, + }; + (fs.readFileSync as Mock).mockImplementation((p) => { + if (p === customPath) return JSON.stringify(userContent); + return '{}'; + }); + + const { rules, errors } = loadTrustedFolders(); + expect(rules).toEqual([ + { + path: '/user/folder/from/env', + trustLevel: TrustLevel.TRUST_FOLDER, + }, + ]); + expect(errors).toEqual([]); + + delete process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + }); + it('setValue should update the user config and save it', () => { const loadedFolders = loadTrustedFolders(); loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER); @@ -121,9 +191,9 @@ describe('Trusted Folders Loading', () => { TrustLevel.TRUST_FOLDER, ); expect(mockFsWriteFileSync).toHaveBeenCalledWith( - USER_TRUSTED_FOLDERS_PATH, + getTrustedFoldersPath(), JSON.stringify({ '/new/path': TrustLevel.TRUST_FOLDER }, null, 2), - 'utf-8', + { encoding: 'utf-8', mode: 0o600 }, ); }); }); @@ -134,22 +204,22 @@ describe('isWorkspaceTrusted', () => { const mockSettings: Settings = { security: { folderTrust: { - featureEnabled: true, enabled: true, }, }, }; beforeEach(() => { + resetTrustedFoldersForTesting(); vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { - if (p === USER_TRUSTED_FOLDERS_PATH) { + if (p === getTrustedFoldersPath()) { return JSON.stringify(mockRules); } return '{}'; }); vi.spyOn(fs, 'existsSync').mockImplementation( - (p) => p === USER_TRUSTED_FOLDERS_PATH, + (p) => p === getTrustedFoldersPath(), ); }); @@ -159,54 +229,198 @@ describe('isWorkspaceTrusted', () => { Object.keys(mockRules).forEach((key) => delete mockRules[key]); }); + it('should throw a fatal error if the config is malformed', () => { + mockCwd = '/home/user/projectA'; + // This mock needs to be specific to this test to override the one in beforeEach + vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { + if (p === getTrustedFoldersPath()) { + return '{"foo": "bar",}'; // Malformed JSON with trailing comma + } + return '{}'; + }); + expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); + expect(() => isWorkspaceTrusted(mockSettings)).toThrow( + /Please fix the configuration file/, + ); + }); + + it('should throw a fatal error if the config is not a JSON object', () => { + mockCwd = '/home/user/projectA'; + vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { + if (p === getTrustedFoldersPath()) { + return 'null'; + } + return '{}'; + }); + expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); + expect(() => isWorkspaceTrusted(mockSettings)).toThrow( + /not a valid JSON object/, + ); + }); + it('should return true for a directly trusted folder', () => { mockCwd = '/home/user/projectA'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return true for a child of a trusted folder', () => { mockCwd = '/home/user/projectA/src'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return true for a child of a trusted parent folder', () => { mockCwd = '/home/user/projectB'; mockRules['/home/user/projectB/somefile.txt'] = TrustLevel.TRUST_PARENT; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should return false for a directly untrusted folder', () => { mockCwd = '/home/user/untrusted'; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBe(false); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: false, + source: 'file', + }); }); it('should return undefined for a child of an untrusted folder', () => { mockCwd = '/home/user/untrusted/src'; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBeUndefined(); + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); }); it('should return undefined when no rules match', () => { mockCwd = '/home/user/other'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBeUndefined(); + expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); }); it('should prioritize trust over distrust', () => { mockCwd = '/home/user/projectA/untrusted'; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); }); it('should handle path normalization', () => { mockCwd = '/home/user/projectA'; mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] = TrustLevel.TRUST_FOLDER; - expect(isWorkspaceTrusted(mockSettings)).toBe(true); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); + }); +}); + +describe('isWorkspaceTrusted with IDE override', () => { + afterEach(() => { + vi.clearAllMocks(); + ideContextStore.clear(); + resetTrustedFoldersForTesting(); + }); + + const mockSettings: Settings = { + security: { + folderTrust: { + enabled: true, + }, + }, + }; + + it('should return true when ideTrust is true, ignoring config', () => { + ideContextStore.set({ workspaceState: { isTrusted: true } }); + // Even if config says don't trust, ideTrust should win. + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ [process.cwd()]: TrustLevel.DO_NOT_TRUST }), + ); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'ide', + }); + }); + + it('should return false when ideTrust is false, ignoring config', () => { + ideContextStore.set({ workspaceState: { isTrusted: false } }); + // Even if config says trust, ideTrust should win. + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }), + ); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: false, + source: 'ide', + }); + }); + + it('should fall back to config when ideTrust is undefined', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }), + ); + expect(isWorkspaceTrusted(mockSettings)).toEqual({ + isTrusted: true, + source: 'file', + }); + }); + + it('should always return true if folderTrust setting is disabled', () => { + const settings: Settings = { + security: { + folderTrust: { + enabled: false, + }, + }, + }; + ideContextStore.set({ workspaceState: { isTrusted: false } }); + expect(isWorkspaceTrusted(settings)).toEqual({ + isTrusted: true, + source: undefined, + }); + }); +}); + +describe('Trusted Folders Caching', () => { + beforeEach(() => { + resetTrustedFoldersForTesting(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('{}'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should cache the loaded folders object', () => { + const readSpy = vi.spyOn(fs, 'readFileSync'); + + // First call should read the file + loadTrustedFolders(); + expect(readSpy).toHaveBeenCalledTimes(1); + + // Second call should use the cache + loadTrustedFolders(); + expect(readSpy).toHaveBeenCalledTimes(1); + + // Resetting should clear the cache + resetTrustedFoldersForTesting(); + + // Third call should read the file again + loadTrustedFolders(); + expect(readSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index fe394d39..60a897f1 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -7,17 +7,25 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; -import { getErrorMessage, isWithinRoot } from '@qwen-code/qwen-code-core'; +import { + FatalConfigError, + getErrorMessage, + isWithinRoot, + ideContextStore, +} from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export const SETTINGS_DIRECTORY_NAME = '.qwen'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); -export const USER_TRUSTED_FOLDERS_PATH = path.join( - USER_SETTINGS_DIR, - TRUSTED_FOLDERS_FILENAME, -); + +export function getTrustedFoldersPath(): string { + if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) { + return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']; + } + return path.join(USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME); +} export enum TrustLevel { TRUST_FOLDER = 'TRUST_FOLDER', @@ -40,10 +48,15 @@ export interface TrustedFoldersFile { path: string; } +export interface TrustResult { + isTrusted: boolean | undefined; + source: 'ide' | 'file' | undefined; +} + export class LoadedTrustedFolders { constructor( - public user: TrustedFoldersFile, - public errors: TrustedFoldersError[], + readonly user: TrustedFoldersFile, + readonly errors: TrustedFoldersError[], ) {} get rules(): TrustRule[] { @@ -53,28 +66,92 @@ export class LoadedTrustedFolders { })); } + /** + * 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): boolean | undefined { + const trustedPaths: string[] = []; + const untrustedPaths: string[] = []; + + for (const rule of this.rules) { + switch (rule.trustLevel) { + case TrustLevel.TRUST_FOLDER: + trustedPaths.push(rule.path); + break; + case TrustLevel.TRUST_PARENT: + trustedPaths.push(path.dirname(rule.path)); + break; + case TrustLevel.DO_NOT_TRUST: + untrustedPaths.push(rule.path); + break; + default: + // Do nothing for unknown trust levels. + break; + } + } + + for (const trustedPath of trustedPaths) { + if (isWithinRoot(location, trustedPath)) { + return true; + } + } + + for (const untrustedPath of untrustedPaths) { + if (path.normalize(location) === path.normalize(untrustedPath)) { + return false; + } + } + + return undefined; + } + setValue(path: string, trustLevel: TrustLevel): void { this.user.config[path] = trustLevel; saveTrustedFolders(this.user); } } -export function loadTrustedFolders(): LoadedTrustedFolders { - const errors: TrustedFoldersError[] = []; - const userConfig: Record = {}; +let loadedTrustedFolders: LoadedTrustedFolders | undefined; - const userPath = USER_TRUSTED_FOLDERS_PATH; +/** + * FOR TESTING PURPOSES ONLY. + * Resets the in-memory cache of the trusted folders configuration. + */ +export function resetTrustedFoldersForTesting(): void { + loadedTrustedFolders = undefined; +} + +export function loadTrustedFolders(): LoadedTrustedFolders { + if (loadedTrustedFolders) { + return loadedTrustedFolders; + } + + const errors: TrustedFoldersError[] = []; + let userConfig: Record = {}; + + const userPath = getTrustedFoldersPath(); // Load user trusted folders try { if (fs.existsSync(userPath)) { const content = fs.readFileSync(userPath, 'utf-8'); - const parsed = JSON.parse(stripJsonComments(content)) as Record< - string, - TrustLevel - >; - if (parsed) { - Object.assign(userConfig, parsed); + const parsed: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + errors.push({ + message: 'Trusted folders file is not a valid JSON object.', + path: userPath, + }); + } else { + userConfig = parsed as Record; } } } catch (error: unknown) { @@ -84,10 +161,11 @@ export function loadTrustedFolders(): LoadedTrustedFolders { }); } - return new LoadedTrustedFolders( + loadedTrustedFolders = new LoadedTrustedFolders( { path: userPath, config: userConfig }, errors, ); + return loadedTrustedFolders; } export function saveTrustedFolders( @@ -103,66 +181,57 @@ export function saveTrustedFolders( fs.writeFileSync( trustedFoldersFile.path, JSON.stringify(trustedFoldersFile.config, null, 2), - 'utf-8', + { encoding: 'utf-8', mode: 0o600 }, ); } catch (error) { console.error('Error saving trusted folders file:', error); } } -export function isWorkspaceTrusted(settings: Settings): boolean | undefined { - const folderTrustFeature = - settings.security?.folderTrust?.featureEnabled ?? false; - const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true; - const folderTrustEnabled = folderTrustFeature && folderTrustSetting; - - if (!folderTrustEnabled) { - return true; - } - - const { rules, errors } = loadTrustedFolders(); - - if (errors.length > 0) { - for (const error of errors) { - console.error( - `Error loading trusted folders config from ${error.path}: ${error.message}`, - ); - } - } - - const trustedPaths: string[] = []; - const untrustedPaths: string[] = []; - - for (const rule of rules) { - switch (rule.trustLevel) { - case TrustLevel.TRUST_FOLDER: - trustedPaths.push(rule.path); - break; - case TrustLevel.TRUST_PARENT: - trustedPaths.push(path.dirname(rule.path)); - break; - case TrustLevel.DO_NOT_TRUST: - untrustedPaths.push(rule.path); - break; - default: - // Do nothing for unknown trust levels. - break; - } - } - - const cwd = process.cwd(); - - for (const trustedPath of trustedPaths) { - if (isWithinRoot(cwd, trustedPath)) { - return true; - } - } - - for (const untrustedPath of untrustedPaths) { - if (path.normalize(cwd) === path.normalize(untrustedPath)) { - return false; - } - } - - return undefined; +/** Is folder trust feature enabled per the current applied settings */ +export function isFolderTrustEnabled(settings: Settings): boolean { + const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false; + return folderTrustSetting; +} + +function getWorkspaceTrustFromLocalConfig( + trustConfig?: Record, +): TrustResult { + const folders = loadTrustedFolders(); + + if (trustConfig) { + folders.user.config = trustConfig; + } + + 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(process.cwd()); + return { + isTrusted, + source: isTrusted !== undefined ? 'file' : undefined, + }; +} + +export function isWorkspaceTrusted( + settings: Settings, + trustConfig?: Record, +): TrustResult { + 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(trustConfig); } diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts new file mode 100644 index 00000000..2284a112 --- /dev/null +++ b/packages/cli/src/core/auth.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AuthType, + type Config, + getErrorMessage, +} from '@qwen-code/qwen-code-core'; + +/** + * Handles the initial authentication flow. + * @param config The application config. + * @param authType The selected auth type. + * @returns An error message if authentication fails, otherwise null. + */ +export async function performInitialAuth( + config: Config, + authType: AuthType | undefined, +): Promise { + if (!authType) { + return 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) { + return `Failed to login. Message: ${getErrorMessage(e)}`; + } + + return null; +} diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts new file mode 100644 index 00000000..039b9277 --- /dev/null +++ b/packages/cli/src/core/initializer.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IdeClient, + IdeConnectionEvent, + IdeConnectionType, + logIdeConnection, + type Config, +} from '@qwen-code/qwen-code-core'; +import { type LoadedSettings } from '../config/settings.js'; +import { performInitialAuth } from './auth.js'; +import { validateTheme } from './theme.js'; + +export interface InitializationResult { + authError: string | 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 authError = await performInitialAuth( + config, + settings.merged.security?.auth?.selectedType, + ); + const themeError = validateTheme(settings); + + const shouldOpenAuthDialog = + settings.merged.security?.auth?.selectedType === undefined || !!authError; + + if (config.getIdeMode()) { + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); + } + + return { + authError, + themeError, + shouldOpenAuthDialog, + geminiMdFileCount: config.getGeminiMdFileCount(), + }; +} diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts new file mode 100644 index 00000000..ed2805a5 --- /dev/null +++ b/packages/cli/src/core/theme.ts @@ -0,0 +1,21 @@ +/** + * @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; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 979a367f..231c34a7 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -4,18 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; import { main, setupUnhandledRejectionHandler, validateDnsResolutionOrder, startInteractiveUI, } from './gemini.js'; -import type { SettingsFile } from './config/settings.js'; -import { LoadedSettings, loadSettings } from './config/settings.js'; +import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { FatalConfigError } from '@qwen-code/qwen-code-core'; // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { @@ -36,14 +42,12 @@ vi.mock('./config/settings.js', async (importOriginal) => { vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockResolvedValue({ - config: { - getSandbox: vi.fn(() => false), - getQuestion: vi.fn(() => ''), - }, - modelWasSwitched: false, - originalModelBeforeSwitch: null, - finalModel: 'test-model', - }), + getSandbox: vi.fn(() => false), + getQuestion: vi.fn(() => ''), + isInteractive: () => false, + } as unknown as Config), + parseArguments: vi.fn().mockResolvedValue({}), + isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ @@ -74,22 +78,21 @@ vi.mock('./utils/sandbox.js', () => ({ start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves })); +vi.mock('./utils/relaunch.js', () => ({ + relaunchAppInChildProcess: vi.fn(), +})); + +vi.mock('./config/sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn(), +})); + describe('gemini.tsx main function', () => { - let loadSettingsMock: ReturnType>; let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = []; - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - beforeEach(() => { - loadSettingsMock = vi.mocked(loadSettings); - // Store and clear sandbox-related env variables to ensure a consistent test environment originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX']; originalEnvSandbox = process.env['SANDBOX']; @@ -124,43 +127,73 @@ describe('gemini.tsx main function', () => { vi.restoreAllMocks(); }); - it('should throw InvalidConfigurationError if settings have errors', async () => { - const settingsError = { - message: 'Test settings error', - path: '/test/settings.json', - }; - const userSettingsFile: SettingsFile = { - path: '/user/settings.json', - settings: {}, - }; - const workspaceSettingsFile: SettingsFile = { - path: '/workspace/.gemini/settings.json', - settings: {}, - }; - const systemSettingsFile: SettingsFile = { - path: '/system/settings.json', - settings: {}, - }; - const systemDefaultsFile: SettingsFile = { - path: '/system/system-defaults.json', - settings: {}, - }; - const mockLoadedSettings = new LoadedSettings( - systemSettingsFile, - systemDefaultsFile, - userSettingsFile, - workspaceSettingsFile, - [settingsError], - true, - new Set(), - ); + it('verifies that we dont load the config before relaunchAppInChildProcess', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const { relaunchAppInChildProcess } = await import('./utils/relaunch.js'); + const { loadCliConfig } = await import('./config/config.js'); + const { loadSettings } = await import('./config/settings.js'); + const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); + vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); - loadSettingsMock.mockReturnValue(mockLoadedSettings); + const callOrder: string[] = []; + vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => { + callOrder.push('relaunch'); + }); + vi.mocked(loadCliConfig).mockImplementation(async () => { + callOrder.push('loadCliConfig'); + return { + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getProjectRoot: () => '/', + } as unknown as Config; + }); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: { autoConfigureMemory: true }, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + } as never); + try { + await main(); + } catch (e) { + // Mocked process exit throws an error. + if (!(e instanceof MockProcessExitError)) throw e; + } - await expect(main()).rejects.toThrow(FatalConfigError); + // It is critical that we call relaunch before loadCliConfig to avoid + // loading config in the outer process when we are going to relaunch. + // By ensuring we don't load the config we also ensure we don't trigger any + // operations that might require loading the config such as such as + // initializing mcp servers. + // For the sandbox case we still have to load a partial cli config. + // we can authorize outside the sandbox. + expect(callOrder).toEqual(['relaunch', 'loadCliConfig']); + processExitSpy.mockRestore(); }); 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 rejectionError = new Error('Test unhandled rejection'); @@ -199,6 +232,117 @@ describe('gemini.tsx main function', () => { }); }); +describe('gemini.tsx main function kitty protocol', () => { + let originalEnvNoRelaunch: string | 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'); + + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isRaw', { + value: false, + configurable: true, + }); + }); + + afterEach(() => { + // Restore original env variables + if (originalEnvNoRelaunch !== undefined) { + process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + } else { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + } + }); + + it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => { + const { detectAndEnableKittyProtocol } = await import( + './ui/utils/kittyProtocolDetector.js' + ); + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + allFiles: undefined, + showMemoryUsage: undefined, + yolo: undefined, + approvalMode: undefined, + telemetry: undefined, + checkpointing: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + proxy: undefined, + includeDirectories: undefined, + tavilyApiKey: undefined, + screenReader: undefined, + vlmSwitchMode: undefined, + useSmartEdit: undefined, + outputFormat: undefined, + }); + + await main(); + + expect(setRawModeSpy).toHaveBeenCalledWith(true); + expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1); + }); +}); + describe('validateDnsResolutionOrder', () => { let consoleWarnSpy: ReturnType; @@ -227,7 +371,6 @@ describe('validateDnsResolutionOrder', () => { it('should return the default "ipv4first" and log a warning for an invalid string', () => { expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); - expect(consoleWarnSpy).toHaveBeenCalledOnce(); expect(consoleWarnSpy).toHaveBeenCalledWith( 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', ); @@ -255,7 +398,7 @@ describe('startInteractiveUI', () => { })); vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({ - detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve()), + detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)), })); vi.mock('./ui/utils/updateCheck.js', () => ({ @@ -279,11 +422,19 @@ describe('startInteractiveUI', () => { const { render } = await import('ink'); const renderSpy = vi.mocked(render); + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + await startInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, + mockInitializationResult, ); // Verify render was called with correct options @@ -302,22 +453,26 @@ describe('startInteractiveUI', () => { it('should perform all startup tasks in correct order', async () => { const { getCliVersion } = await import('./utils/version.js'); - const { detectAndEnableKittyProtocol } = await import( - './ui/utils/kittyProtocolDetector.js' - ); const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); const { registerCleanup } = await import('./utils/cleanup.js'); + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + await startInteractiveUI( mockConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, + mockInitializationResult, ); // Verify all startup tasks were called expect(getCliVersion).toHaveBeenCalledTimes(1); - expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1); expect(registerCleanup).toHaveBeenCalledTimes(1); // Verify cleanup handler is registered with unmount function diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 361bd5b7..d5ffd023 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,47 +4,63 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; +import { render } from 'ink'; +import { AppContainer } from './ui/AppContainer.js'; +import { loadCliConfig, parseArguments } from './config/config.js'; +import * as cliConfig from './config/config.js'; +import { readStdin } from './utils/readStdin.js'; +import { basename } from 'node:path'; +import v8 from 'node:v8'; +import os from 'node:os'; +import dns from 'node:dns'; +import { randomUUID } from 'node:crypto'; +import { start_sandbox } from './utils/sandbox.js'; +import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; +import { + loadSettings, + migrateDeprecatedSettings, + SettingScope, +} from './config/settings.js'; +import { themeManager } from './ui/themes/theme-manager.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 { ExtensionStorage, loadExtensions } from './config/extension.js'; +import { + cleanupCheckpoints, + registerCleanup, + runExitCleanup, +} from './utils/cleanup.js'; +import { getCliVersion } from './utils/version.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { AuthType, - FatalConfigError, getOauthClient, - IdeConnectionEvent, - IdeConnectionType, - logIdeConnection, logUserPrompt, - sessionId, } from '@qwen-code/qwen-code-core'; -import { render } from 'ink'; -import { spawn } from 'node:child_process'; -import dns from 'node:dns'; -import os from 'node:os'; -import { basename } from 'node:path'; -import v8 from 'node:v8'; -import React from 'react'; +import { + initializeApp, + type InitializationResult, +} from './core/initializer.js'; import { validateAuthMethod } from './config/auth.js'; -import { loadCliConfig, parseArguments } from './config/config.js'; -import { loadExtensions } from './config/extension.js'; -import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { loadSettings, SettingScope } from './config/settings.js'; -import { runNonInteractive } from './nonInteractiveCli.js'; -import { AppWrapper } from './ui/App.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; -import { themeManager } from './ui/themes/theme-manager.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; -import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js'; -import { AppEvent, appEvents } from './utils/events.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; -import { readStdin } from './utils/readStdin.js'; -import { start_sandbox } from './utils/sandbox.js'; -import { getStartupWarnings } from './utils/startupWarnings.js'; -import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; -import { getCliVersion } from './utils/version.js'; +import { computeWindowTitle } from './utils/windowTitle.js'; +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { appEvents, AppEvent } from './utils/events.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { + relaunchOnExitCode, + relaunchAppInChildProcess, +} from './utils/relaunch.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { runZedIntegration } from './zed-integration/zedIntegration.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -63,7 +79,7 @@ export function validateDnsResolutionOrder( return defaultValue; } -function getNodeMemoryArgs(config: Config): string[] { +function getNodeMemoryArgs(isDebugMode: boolean): string[] { const totalMemoryMB = os.totalmem() / (1024 * 1024); const heapStats = v8.getHeapStatistics(); const currentMaxOldSpaceSizeMb = Math.floor( @@ -72,7 +88,7 @@ function getNodeMemoryArgs(config: Config): string[] { // Set target to 50% of total memory const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); - if (config.getDebugMode()) { + if (isDebugMode) { console.debug( `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, ); @@ -83,7 +99,7 @@ function getNodeMemoryArgs(config: Config): string[] { } if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { - if (config.getDebugMode()) { + if (isDebugMode) { console.debug( `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, ); @@ -94,18 +110,9 @@ function getNodeMemoryArgs(config: Config): string[] { return []; } -async function relaunchWithAdditionalArgs(additionalArgs: string[]) { - const nodeArgs = [...additionalArgs, ...process.argv.slice(1)]; - const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' }; - - const child = spawn(process.execPath, nodeArgs, { - stdio: 'inherit', - env: newEnv, - }); - - await new Promise((resolve) => child.on('close', resolve)); - process.exit(0); -} +import { runZedIntegration } from './zed-integration/zedIntegration.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; @@ -133,24 +140,54 @@ export async function startInteractiveUI( config: Config, settings: LoadedSettings, startupWarnings: string[], - workspaceRoot: string, + workspaceRoot: string = process.cwd(), + initializationResult: InitializationResult, ) { const version = await getCliVersion(); - // Detect and enable Kitty keyboard protocol once at startup - await detectAndEnableKittyProtocol(); setWindowTitle(basename(workspaceRoot), settings); - const instance = render( - + + // Create wrapper component to use hooks inside render + const AppWrapper = () => { + const kittyProtocolStatus = useKittyKeyboardProtocol(); + const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); + return ( - + debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging} + pasteWorkaround={ + process.platform === 'win32' || nodeMajorVersion < 20 + } + > + + + + + + - , - { exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() }, + ); + }; + + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + }, ); checkForUpdates() @@ -169,31 +206,25 @@ export async function startInteractiveUI( export async function main() { setupUnhandledRejectionHandler(); - const workspaceRoot = process.cwd(); - const settings = loadSettings(workspaceRoot); - + const settings = loadSettings(); + migrateDeprecatedSettings(settings); await cleanupCheckpoints(); - if (settings.errors.length > 0) { - const errorMessages = settings.errors.map( - (error) => `Error in ${error.path}: ${error.message}`, - ); - throw new FatalConfigError( - `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, - ); - } + const sessionId = randomUUID(); const argv = await parseArguments(settings.merged); - const extensions = loadExtensions(workspaceRoot); - const config = await loadCliConfig( - settings.merged, - extensions, - sessionId, - argv, - ); + // Check for invalid input combinations early to prevent crashes + if (argv.promptInteractive && !process.stdin.isTTY) { + console.error( + 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.', + ); + process.exit(1); + } + + const isDebugMode = cliConfig.isDebugMode(argv); const consolePatcher = new ConsolePatcher({ stderr: true, - debugMode: config.getDebugMode(), + debugMode: isDebugMode, }); consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); @@ -202,21 +233,6 @@ export async function main() { validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), ); - if (argv.promptInteractive && !process.stdin.isTTY) { - console.error( - 'Error: The --prompt-interactive flag is not supported when piping input from stdin.', - ); - process.exit(1); - } - - if (config.getListExtensions()) { - console.log('Installed extensions:'); - for (const extension of extensions) { - console.log(`- ${extension.config.name}`); - } - process.exit(0); - } - // Set a default auth type if one isn't set. if (!settings.merged.security?.auth?.selectedType) { if (process.env['CLOUD_SHELL'] === 'true') { @@ -227,23 +243,6 @@ export async function main() { ); } } - // Empty key causes issues with the GoogleGenAI package. - if (process.env['GEMINI_API_KEY']?.trim() === '') { - delete process.env['GEMINI_API_KEY']; - } - - if (process.env['GOOGLE_API_KEY']?.trim() === '') { - delete process.env['GOOGLE_API_KEY']; - } - - setMaxSizedBoxDebugging(config.getDebugMode()); - - await config.initialize(); - - if (config.getIdeMode()) { - await config.getIdeClient().connect(); - logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); - } // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); @@ -251,7 +250,7 @@ export async function main() { if (settings.merged.ui?.theme) { if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { // If the theme is not found during initial load, log a warning and continue. - // The useThemeCommand hook in App.tsx will handle opening the dialog. + // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`); } } @@ -259,10 +258,24 @@ export async function main() { // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { const memoryArgs = settings.merged.advanced?.autoConfigureMemory - ? getNodeMemoryArgs(config) + ? getNodeMemoryArgs(isDebugMode) : []; - const sandboxConfig = config.getSandbox(); + const sandboxConfig = await loadSandboxConfig(settings.merged, argv); + // We intentially 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) { + const partialConfig = await loadCliConfig( + settings.merged, + [], + new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), + sessionId, + argv, + ); + if ( settings.merged.security?.auth?.selectedType && !settings.merged.security?.auth?.useExternal @@ -275,7 +288,10 @@ export async function main() { if (err) { throw new Error(err); } - await config.refreshAuth(settings.merged.security.auth.selectedType); + + await partialConfig.refreshAuth( + settings.merged.security.auth.selectedType, + ); } catch (err) { console.error('Error authenticating:', err); process.exit(1); @@ -311,88 +327,146 @@ export async function main() { const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData); - await start_sandbox(sandboxConfig, memoryArgs, config, sandboxArgs); + await relaunchOnExitCode(() => + start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), + ); process.exit(0); } else { - // Not in a sandbox and not entering one, so relaunch with additional - // arguments to control memory usage if needed. - if (memoryArgs.length > 0) { - await relaunchWithAdditionalArgs(memoryArgs); - process.exit(0); + // Relaunch app so we always have a child process that can be internally + // restarted if needed. + await relaunchAppInChildProcess(memoryArgs, []); + } + } + + // 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 extensionEnablementManager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ); + const extensions = loadExtensions(extensionEnablementManager); + const config = await loadCliConfig( + settings.merged, + extensions, + extensionEnablementManager, + sessionId, + argv, + ); + + if (config.getListExtensions()) { + console.log('Installed extensions:'); + for (const extension of extensions) { + console.log(`- ${extension.config.name}`); + } + process.exit(0); + } + + const wasRaw = process.stdin.isRaw; + let kittyProtocolDetectionComplete: Promise | undefined; + 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. + process.on('SIGTERM', () => { + process.stdin.setRawMode(wasRaw); + }); + process.on('SIGINT', () => { + process.stdin.setRawMode(wasRaw); + }); + + // Detect and enable Kitty keyboard protocol once at startup. + kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); + } + + setMaxSizedBoxDebugging(isDebugMode); + + const initializationResult = await initializeApp(config, settings); + + 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.getExperimentalZedIntegration()) { + return runZedIntegration(config, settings, extensions, argv); + } + + let input = config.getQuestion(); + const startupWarnings = [ + ...(await getStartupWarnings()), + ...(await getUserStartupWarnings()), + ]; + + // Render UI, passing necessary config values. Check that there is no command line question. + if (config.isInteractive()) { + // Need kitty detection to be complete before we can start the interactive UI. + await kittyProtocolDetectionComplete; + await startInteractiveUI( + config, + settings, + startupWarnings, + process.cwd(), + initializationResult, + ); + return; + } + + await config.initialize(); + + // If not a TTY, read from stdin + // This is for cases where the user pipes input directly into the command + if (!process.stdin.isTTY) { + const stdinData = await readStdin(); + if (stdinData) { + input = `${stdinData}\n\n${input}`; } } - } - - 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.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, extensions, argv); - } - - let input = config.getQuestion(); - const startupWarnings = [ - ...(await getStartupWarnings()), - ...(await getUserStartupWarnings(workspaceRoot)), - ]; - - // Render UI, passing necessary config values. Check that there is no command line question. - if (config.isInteractive()) { - await startInteractiveUI(config, settings, startupWarnings, workspaceRoot); - return; - } - // If not a TTY, read from stdin - // This is for cases where the user pipes input directly into the command - if (!process.stdin.isTTY) { - const stdinData = await readStdin(); - if (stdinData) { - input = `${stdinData}\n\n${input}`; + if (!input) { + console.error( + `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, + ); + process.exit(1); } - } - if (!input) { - console.error( - `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, + + const prompt_id = Math.random().toString(16).slice(2); + logUserPrompt(config, { + 'event.name': 'user_prompt', + 'event.timestamp': new Date().toISOString(), + prompt: input, + prompt_id, + auth_type: config.getContentGeneratorConfig()?.authType, + prompt_length: input.length, + }); + + const nonInteractiveConfig = await validateNonInteractiveAuth( + settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.useExternal, + config, + settings, ); - process.exit(1); + + if (config.getDebugMode()) { + console.log('Session ID: %s', sessionId); + } + + await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id); + // Call cleanup before process.exit, which causes cleanup to not run + await runExitCleanup(); + process.exit(0); } - - const prompt_id = Math.random().toString(16).slice(2); - logUserPrompt(config, { - 'event.name': 'user_prompt', - 'event.timestamp': new Date().toISOString(), - prompt: input, - prompt_id, - auth_type: config.getContentGeneratorConfig()?.authType, - prompt_length: input.length, - }); - - const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - config, - ); - - if (config.getDebugMode()) { - console.log('Session ID: %s', sessionId); - } - - await runNonInteractive(nonInteractiveConfig, input, prompt_id); - process.exit(0); } function setWindowTitle(title: string, settings: LoadedSettings) { if (!settings.merged.ui?.hideWindowTitle) { - const windowTitle = (process.env['CLI_TITLE'] || `Qwen - ${title}`).replace( - // eslint-disable-next-line no-control-regex - /[\x00-\x1F\x7F]/g, - '', - ); + const windowTitle = computeWindowTitle(title); process.stdout.write(`\x1b]2;${windowTitle}\x07`); process.on('exit', () => { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 7d5727c2..1aad835e 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -4,34 +4,62 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { + Config, + ToolRegistry, + ServerGeminiStreamEvent, + SessionMetrics, +} from '@qwen-code/qwen-code-core'; import { - type Config, - type ToolRegistry, executeToolCall, ToolErrorType, shutdownTelemetry, GeminiEventType, - type ServerGeminiStreamEvent, + OutputFormat, + uiTelemetryService, + FatalInputError, } from '@qwen-code/qwen-code-core'; -import { type Part } from '@google/genai'; +import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; import { vi } from 'vitest'; +import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); + + class MockChatRecordingService { + initialize = vi.fn(); + recordMessage = vi.fn(); + recordMessageTokens = vi.fn(); + recordToolCalls = vi.fn(); + } + return { ...original, executeToolCall: vi.fn(), shutdownTelemetry: vi.fn(), isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), + ChatRecordingService: MockChatRecordingService, + uiTelemetryService: { + getMetrics: vi.fn(), + }, }; }); +const mockGetCommands = vi.hoisted(() => vi.fn()); +const mockCommandServiceCreate = vi.hoisted(() => vi.fn()); +vi.mock('./services/CommandService.js', () => ({ + CommandService: { + create: mockCommandServiceCreate, + }, +})); + describe('runNonInteractive', () => { let mockConfig: Config; + let mockSettings: LoadedSettings; let mockToolRegistry: ToolRegistry; let mockCoreExecuteToolCall: vi.Mock; let mockShutdownTelemetry: vi.Mock; @@ -39,16 +67,24 @@ describe('runNonInteractive', () => { let processStdoutSpy: vi.SpyInstance; let mockGeminiClient: { sendMessageStream: vi.Mock; + getChatRecordingService: vi.Mock; }; beforeEach(async () => { mockCoreExecuteToolCall = vi.mocked(executeToolCall); mockShutdownTelemetry = vi.mocked(shutdownTelemetry); + mockCommandServiceCreate.mockResolvedValue({ + getCommands: mockGetCommands, + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code}) called`); + }); mockToolRegistry = { getTool: vi.fn(), @@ -57,6 +93,12 @@ describe('runNonInteractive', () => { mockGeminiClient = { sendMessageStream: vi.fn(), + getChatRecordingService: vi.fn(() => ({ + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + })), }; mockConfig = { @@ -64,12 +106,40 @@ describe('runNonInteractive', () => { 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), getFullContext: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getDebugMode: vi.fn().mockReturnValue(false), + getOutputFormat: vi.fn().mockReturnValue('text'), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: 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, + migratedInMemorScopes: new Set(), + forScope: vi.fn(), + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; + const { handleAtCommand } = await import( './ui/hooks/atCommandProcessor.js' ); @@ -95,12 +165,21 @@ describe('runNonInteractive', () => { 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(mockConfig, 'Test input', 'prompt-id-1'); + await runNonInteractive( + mockConfig, + mockSettings, + 'Test input', + 'prompt-id-1', + ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( [{ text: 'Test input' }], @@ -130,13 +209,22 @@ describe('runNonInteractive', () => { 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(mockConfig, 'Use a tool', 'prompt-id-2'); + await runNonInteractive( + mockConfig, + mockSettings, + 'Use a tool', + 'prompt-id-2', + ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( @@ -185,12 +273,21 @@ describe('runNonInteractive', () => { 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(mockConfig, 'Trigger tool error', 'prompt-id-3'); + await runNonInteractive( + mockConfig, + mockSettings, + 'Trigger tool error', + 'prompt-id-3', + ); expect(mockCoreExecuteToolCall).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -222,7 +319,12 @@ describe('runNonInteractive', () => { }); await expect( - runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'), + runNonInteractive( + mockConfig, + mockSettings, + 'Initial fail', + 'prompt-id-4', + ), ).rejects.toThrow(apiError); }); @@ -240,12 +342,17 @@ describe('runNonInteractive', () => { mockCoreExecuteToolCall.mockResolvedValue({ error: new Error('Tool "nonexistentTool" not found in registry.'), resultDisplay: 'Tool "nonexistentTool" not found in registry.', + responseParts: [], }); 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 @@ -254,6 +361,7 @@ describe('runNonInteractive', () => { await runNonInteractive( mockConfig, + mockSettings, 'Trigger tool not found', 'prompt-id-5', ); @@ -271,10 +379,13 @@ describe('runNonInteractive', () => { it('should exit when max session turns are exceeded', async () => { vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); await expect( - runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'), - ).rejects.toThrow( - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', - ); + runNonInteractive( + mockConfig, + mockSettings, + 'Trigger loop', + 'prompt-id-6', + ), + ).rejects.toThrow('process.exit(53) called'); }); it('should preprocess @include commands before sending to the model', async () => { @@ -302,13 +413,17 @@ describe('runNonInteractive', () => { // 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(mockConfig, rawInput, 'prompt-id-7'); + await runNonInteractive(mockConfig, mockSettings, rawInput, 'prompt-id-7'); // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( @@ -320,4 +435,442 @@ describe('runNonInteractive', () => { // 6. Assert the final output is correct expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.'); }); + + 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); + const mockMetrics: 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, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Test input', + 'prompt-id-1', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + ); + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: 'Hello World', stats: mockMetrics }, 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' }]; + mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); + + // 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); + const mockMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { + accept: 1, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: { + testTool: { + count: 1, + success: 1, + fail: 0, + durationMs: 100, + decisions: { + accept: 1, + reject: 0, + modify: 0, + auto_accept: 0, + }, + }, + }, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Execute tool only', + 'prompt-id-tool-only', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ name: 'testTool' }), + expect.any(AbortSignal), + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: '', stats: mockMetrics }, 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); + const mockMetrics: 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, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Empty response test', + 'prompt-id-empty', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Empty response test' }], + expect.any(AbortSignal), + 'prompt-id-empty', + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: '', stats: mockMetrics }, 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; + }); + + // Mock console.error to capture JSON error output + const consoleErrorJsonSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + let thrownError: Error | null = null; + try { + await runNonInteractive( + mockConfig, + mockSettings, + 'Test input', + '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(consoleErrorJsonSpy).toHaveBeenCalledWith( + JSON.stringify( + { + 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; + }); + + // Mock console.error to capture JSON error output + const consoleErrorJsonSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + let thrownError: Error | null = null; + try { + await runNonInteractive( + mockConfig, + mockSettings, + 'Invalid syntax', + '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(consoleErrorJsonSpy).toHaveBeenCalledWith( + JSON.stringify( + { + 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( + mockConfig, + mockSettings, + '/testcommand', + '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', + ); + + expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); + }); + + 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( + mockConfig, + mockSettings, + '/confirm', + '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( + mockConfig, + mockSettings, + '/unknowncommand', + 'prompt-id-unknown', + ); + + // Ensure the raw input is sent to the model + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: '/unknowncommand' }], + expect.any(AbortSignal), + 'prompt-id-unknown', + ); + + expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); + }); + + 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( + mockConfig, + mockSettings, + '/noaction', + '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( + mockConfig, + mockSettings, + '/testargs arg1 arg2', + 'prompt-id-args', + ); + + expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); + + expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged'); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bdc42f46..37f02fab 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -5,134 +5,175 @@ */ import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; +import { isSlashCommand } from './ui/utils/commandUtils.js'; +import type { LoadedSettings } from './config/settings.js'; import { executeToolCall, shutdownTelemetry, isTelemetrySdkInitialized, GeminiEventType, - parseAndFormatApiError, FatalInputError, - FatalTurnLimitedError, + promptIdContext, + OutputFormat, + JsonFormatter, + uiTelemetryService, } from '@qwen-code/qwen-code-core'; + import type { Content, Part } from '@google/genai'; +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'; export async function runNonInteractive( config: Config, + settings: LoadedSettings, input: string, prompt_id: string, ): Promise { - const consolePatcher = new ConsolePatcher({ - stderr: true, - debugMode: config.getDebugMode(), - }); - - try { - consolePatcher.patch(); - // 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); - } + return promptIdContext.run(prompt_id, async () => { + const consolePatcher = new ConsolePatcher({ + stderr: true, + debugMode: config.getDebugMode(), }); - const geminiClient = config.getGeminiClient(); + try { + consolePatcher.patch(); + // 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 abortController = new AbortController(); + const geminiClient = config.getGeminiClient(); - const { processedQuery, shouldProceed } = await handleAtCommand({ - query: input, - config, - addItem: (_item, _timestamp) => 0, - onDebugMessage: () => {}, - messageId: Date.now(), - signal: abortController.signal, - }); + const abortController = new AbortController(); - if (!shouldProceed || !processedQuery) { - // An error occurred during @include processing (e.g., file not found). - // The error message is already logged by handleAtCommand. - throw new FatalInputError( - 'Exiting due to an error processing the @ command.', - ); - } + let query: Part[] | undefined; - let currentMessages: Content[] = [ - { role: 'user', parts: processedQuery as Part[] }, - ]; - - let turnCount = 0; - while (true) { - turnCount++; - if ( - config.getMaxSessionTurns() >= 0 && - turnCount > config.getMaxSessionTurns() - ) { - throw new FatalTurnLimitedError( - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + 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 fall through to the default prompt + // handling. + if (slashCommandResult) { + query = slashCommandResult as Part[]; + } } - const toolCallRequests: ToolCallRequestInfo[] = []; - const responseStream = geminiClient.sendMessageStream( - currentMessages[0]?.parts || [], - abortController.signal, - prompt_id, - ); + if (!query) { + const { processedQuery, shouldProceed } = await handleAtCommand({ + query: input, + config, + addItem: (_item, _timestamp) => 0, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: abortController.signal, + }); - for await (const event of responseStream) { - if (abortController.signal.aborted) { - console.error('Operation cancelled.'); + if (!shouldProceed || !processedQuery) { + // An error occurred during @include processing (e.g., file not found). + // The error message is already logged by handleAtCommand. + throw new FatalInputError( + 'Exiting due to an error processing the @ command.', + ); + } + query = processedQuery as Part[]; + } + + 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, + ); + + let responseText = ''; + for await (const event of responseStream) { + if (abortController.signal.aborted) { + handleCancellationError(config); + } + + if (event.type === GeminiEventType.Content) { + if (config.getOutputFormat() === OutputFormat.JSON) { + responseText += event.value; + } else { + process.stdout.write(event.value); + } + } else if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } + } + + if (toolCallRequests.length > 0) { + const toolResponseParts: Part[] = []; + for (const requestInfo of toolCallRequests) { + const toolResponse = await executeToolCall( + config, + requestInfo, + abortController.signal, + ); + + 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); + } + } + currentMessages = [{ role: 'user', parts: toolResponseParts }]; + } else { + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + process.stdout.write(formatter.format(responseText, stats)); + } else { + process.stdout.write('\n'); // Ensure a final newline + } return; } - - if (event.type === GeminiEventType.Content) { - process.stdout.write(event.value); - } else if (event.type === GeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); - } } - - if (toolCallRequests.length > 0) { - const toolResponseParts: Part[] = []; - for (const requestInfo of toolCallRequests) { - const toolResponse = await executeToolCall( - config, - requestInfo, - abortController.signal, - ); - - if (toolResponse.error) { - console.error( - `Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`, - ); - } - - if (toolResponse.responseParts) { - toolResponseParts.push(...toolResponse.responseParts); - } - } - currentMessages = [{ role: 'user', parts: toolResponseParts }]; - } else { - process.stdout.write('\n'); // Ensure a final newline - return; + } catch (error) { + handleError(error, config); + } finally { + consolePatcher.cleanup(); + if (isTelemetrySdkInitialized()) { + await shutdownTelemetry(config); } } - } catch (error) { - console.error( - parseAndFormatApiError( - error, - config.getContentGeneratorConfig()?.authType, - ), - ); - throw error; - } finally { - consolePatcher.cleanup(); - if (isTelemetrySdkInitialized()) { - await shutdownTelemetry(config); - } - } + }); } diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts new file mode 100644 index 00000000..166a1706 --- /dev/null +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -0,0 +1,109 @@ +/** + * @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 '@qwen-code/qwen-code-core'; +import { CommandService } from './services/CommandService.js'; +import { FileCommandLoader } from './services/FileCommandLoader.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; + } + + // Only custom commands are supported for now. + const loaders = [new FileCommandLoader(config)]; + const commandService = await CommandService.create( + loaders, + 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 context: CommandContext = { + services: { + 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(context, 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; +}; diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 36136e13..5f943584 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -23,17 +23,35 @@ vi.mock('../ui/commands/approvalModeCommand.js', () => ({ }, })); -vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() })); +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 type { Config } from '@qwen-code/qwen-code-core'; import { CommandKind } from '../ui/commands/types.js'; -import { ideCommand } from '../ui/commands/ideCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); @@ -49,7 +67,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({ })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); -vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} })); +vi.mock('../ui/commands/modelCommand.js', () => ({ + modelCommand: { name: 'model' }, +})); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {}, quitConfirmCommand: {}, @@ -75,18 +95,15 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ describe('BuiltinCommandLoader', () => { let mockConfig: Config; - const ideCommandMock = ideCommand as Mock; const restoreCommandMock = restoreCommand as Mock; beforeEach(() => { vi.clearAllMocks(); - mockConfig = { some: 'config' } as unknown as Config; + mockConfig = { + getFolderTrust: vi.fn().mockReturnValue(true), + getUseModelRouter: () => false, + } as unknown as Config; - ideCommandMock.mockReturnValue({ - name: 'ide', - description: 'IDE command', - kind: CommandKind.BUILT_IN, - }); restoreCommandMock.mockReturnValue({ name: 'restore', description: 'Restore command', @@ -94,25 +111,23 @@ describe('BuiltinCommandLoader', () => { }); }); - it('should correctly pass the config object to command factory functions', async () => { + it('should correctly pass the config object to restore command factory', async () => { const loader = new BuiltinCommandLoader(mockConfig); await loader.loadCommands(new AbortController().signal); - expect(ideCommandMock).toHaveBeenCalledTimes(1); - expect(ideCommandMock).toHaveBeenCalledWith(mockConfig); + // 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 () => { - // Override the mock's behavior for this specific test. - ideCommandMock.mockReturnValue(null); + // ideCommand is now a constant SlashCommand const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); - // The 'ide' command should be filtered out. + // The 'ide' command should be present. const ideCmd = commands.find((c) => c.name === 'ide'); - expect(ideCmd).toBeUndefined(); + expect(ideCmd).toBeDefined(); // Other commands should still be present. const aboutCmd = commands.find((c) => c.name === 'about'); @@ -122,8 +137,7 @@ describe('BuiltinCommandLoader', () => { it('should handle a null config gracefully when calling factories', async () => { const loader = new BuiltinCommandLoader(null); await loader.loadCommands(new AbortController().signal); - expect(ideCommandMock).toHaveBeenCalledTimes(1); - expect(ideCommandMock).toHaveBeenCalledWith(null); + // ideCommand is now a constant, no longer needs config expect(restoreCommandMock).toHaveBeenCalledTimes(1); expect(restoreCommandMock).toHaveBeenCalledWith(null); }); @@ -149,4 +163,27 @@ describe('BuiltinCommandLoader', () => { const modelCmd = commands.find((c) => c.name === 'model'); expect(modelCmd).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 always include modelCommand', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const modelCmd = commands.find((c) => c.name === 'model'); + expect(modelCmd).toBeDefined(); + expect(modelCmd?.name).toBe('model'); + }); }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 2d162333..5a5a8f2d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -27,7 +27,7 @@ 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 { privacyCommand } from '../ui/commands/privacyCommand.js'; +import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; @@ -70,12 +70,12 @@ export class BuiltinCommandLoader implements ICommandLoader { editorCommand, extensionsCommand, helpCommand, - ideCommand(this.config), + await ideCommand(), initCommand, mcpCommand, memoryCommand, modelCommand, - privacyCommand, + ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, quitConfirmCommand, restoreCommand(this.config), diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index aef6c408..7713775c 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -224,6 +224,8 @@ describe('FileCommandLoader', () => { const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -267,6 +269,8 @@ describe('FileCommandLoader', () => { const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => []), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -556,6 +560,8 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -607,6 +613,8 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -714,6 +722,8 @@ describe('FileCommandLoader', () => { path: extensionDir2, }, ]), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -750,6 +760,8 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -782,6 +794,8 @@ describe('FileCommandLoader', () => { getExtensions: vi.fn(() => [ { name: 'a', version: '1.0.0', isActive: true, path: extensionDir }, ]), + getFolderTrustFeature: vi.fn(() => false), + getFolderTrust: vi.fn(() => false), } as unknown as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -1169,4 +1183,48 @@ describe('FileCommandLoader', () => { } }); }); + + describe('with folder trust enabled', () => { + it('loads multiple commands', async () => { + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + getFolderTrustFeature: vi.fn(() => true), + getFolderTrust: 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(() => []), + getFolderTrustFeature: vi.fn(() => true), + getFolderTrust: 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); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 38365b96..fe485fa2 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -63,8 +63,12 @@ const TomlCommandDefSchema = z.object({ */ export class FileCommandLoader implements ICommandLoader { private readonly projectRoot: string; + private readonly folderTrustEnabled: boolean; + private readonly folderTrust: boolean; constructor(private readonly config: Config | null) { + this.folderTrustEnabled = !!config?.getFolderTrustFeature(); + this.folderTrust = !!config?.getFolderTrust(); this.projectRoot = config?.getProjectRoot() || process.cwd(); } @@ -97,6 +101,10 @@ export class FileCommandLoader implements ICommandLoader { cwd: dirInfo.path, }); + if (this.folderTrustEnabled && !this.folderTrust) { + return []; + } + const commandPromises = files.map((file) => this.parseAndAdaptFile( path.join(dirInfo.path, file), diff --git a/packages/cli/src/services/McpPromptLoader.test.ts b/packages/cli/src/services/McpPromptLoader.test.ts index 27a6256b..c58c3daa 100644 --- a/packages/cli/src/services/McpPromptLoader.test.ts +++ b/packages/cli/src/services/McpPromptLoader.test.ts @@ -7,11 +7,42 @@ import { McpPromptLoader } from './McpPromptLoader.js'; import type { Config } from '@qwen-code/qwen-code-core'; import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CommandKind, type CommandContext } from '../ui/commands/types.js'; +import * as cliCore from '@qwen-code/qwen-code-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: { 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); @@ -125,4 +156,244 @@ describe('McpPromptLoader', () => { }); }); }); + + describe('loadCommands', () => { + const mockConfigWithPrompts = { + 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 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('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([]); + }); + }); + }); }); diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index 349145f0..47858a13 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -144,23 +144,69 @@ export class McpPromptLoader implements ICommandLoader { }; } }, - completion: async (_: CommandContext, partialArg: string) => { - if (!prompt || !prompt.arguments) { + completion: async ( + commandContext: CommandContext, + partialArg: string, + ) => { + const invocation = commandContext.invocation; + if (!prompt || !prompt.arguments || !invocation) { return []; } - - const suggestions: string[] = []; - const usedArgNames = new Set( - (partialArg.match(/--([^=]+)/g) || []).map((s) => s.substring(2)), - ); - - for (const arg of prompt.arguments) { - if (!usedArgNames.has(arg.name)) { - suggestions.push(`--${arg.name}=""`); - } + const indexOfFirstSpace = invocation.raw.indexOf(' ') + 1; + let promptInputs = + indexOfFirstSpace === 0 + ? {} + : this.parseArgs( + invocation.raw.substring(indexOfFirstSpace), + prompt.arguments, + ); + if (promptInputs instanceof Error) { + promptInputs = {}; } - return suggestions; + 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); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 54b1830f..cbfca4d2 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -71,6 +71,7 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), + getShellExecutionConfig: vi.fn().mockReturnValue({}), }; context = createMockCommandContext({ @@ -147,6 +148,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'The current status is: On branch main' }]); }); @@ -218,6 +220,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]); }); @@ -410,6 +413,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); @@ -574,6 +578,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'Command: match found' }]); @@ -598,6 +603,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([ @@ -668,6 +674,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); @@ -697,6 +704,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 3aec590f..c10526e6 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -20,6 +20,7 @@ import { 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( @@ -159,12 +160,19 @@ export class ShellProcessor implements IPromptProcessor { // 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.getShouldUseNodePtyShell(), + shellExecutionConfig, ); const executionResult = await result; diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts new file mode 100644 index 00000000..cf20c488 --- /dev/null +++ b/packages/cli/src/test-utils/createExtension.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, +} from '../config/extension.js'; +import { + type MCPServerConfig, + type ExtensionInstallMetadata, +} from '@qwen-code/qwen-code-core'; + +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, +} = {}): 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 }), + ); + + if (addContextFile) { + fs.writeFileSync(path.join(extDir, 'QWEN.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; +} diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 170620b5..b638ab20 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -57,6 +57,8 @@ export const createMockCommandContext = ( loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), + extensionsUpdateState: new Map(), + setExtensionsUpdateState: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 0aff7c74..690d765d 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -6,13 +6,30 @@ import { render } from 'ink-testing-library'; import type React from 'react'; +import { 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'; + +const mockSettings = new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + true, + new Set(), +); export const renderWithProviders = ( component: React.ReactElement, + { shellFocus = true, settings = mockSettings } = {}, ): ReturnType => render( - - {component} - , + + + + {component} + + + , ); diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index e29aee24..d8883e67 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -4,1735 +4,159 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - AccessibilitySettings, - AuthType, - GeminiClient, - MCPServerConfig, - SandboxConfig, - ToolRegistry, -} from '@qwen-code/qwen-code-core'; -import { - ApprovalMode, - Config as ServerConfig, - ideContext, -} from '@qwen-code/qwen-code-core'; -import { waitFor } from '@testing-library/react'; -import { EventEmitter } from 'node:events'; -import process from 'node:process'; -import type { Mock } from 'vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as auth from '../config/auth.js'; -import { - LoadedSettings, - type Settings, - type SettingsFile, -} from '../config/settings.js'; -import { renderWithProviders } from '../test-utils/render.js'; -import { updateEventEmitter } from '../utils/updateEventEmitter.js'; -import { AppWrapper as App } from './App.js'; -import { Tips } from './components/Tips.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; -import { useGeminiStream } from './hooks/useGeminiStream.js'; -import * as useTerminalSize from './hooks/useTerminalSize.js'; -import type { ConsoleMessageItem } from './types.js'; -import { StreamingState, ToolCallStatus } from './types.js'; -import type { UpdateObject } from './utils/updateCheck.js'; -import { checkForUpdates } from './utils/updateCheck.js'; - -// Define a more complete mock server config based on actual Config -interface MockServerConfig { - apiKey: string; - model: string; - sandbox?: SandboxConfig; - targetDir: string; - debugMode: boolean; - question?: string; - fullContext: boolean; - coreTools?: string[]; - toolDiscoveryCommand?: string; - toolCallCommand?: string; - mcpServerCommand?: string; - mcpServers?: Record; // Use imported MCPServerConfig - userAgent: string; - userMemory: string; - geminiMdFileCount: number; - approvalMode: ApprovalMode; - vertexai?: boolean; - showMemoryUsage?: boolean; - accessibility?: AccessibilitySettings; - embeddingModel: string; - checkpointing?: boolean; - - getApiKey: Mock<() => string>; - getModel: Mock<() => string>; - getSandbox: Mock<() => SandboxConfig | undefined>; - getTargetDir: Mock<() => string>; - getToolRegistry: Mock<() => ToolRegistry>; // Use imported ToolRegistry type - getDebugMode: Mock<() => boolean>; - getQuestion: Mock<() => string | undefined>; - getFullContext: Mock<() => boolean>; - getCoreTools: Mock<() => string[] | undefined>; - getToolDiscoveryCommand: Mock<() => string | undefined>; - getToolCallCommand: Mock<() => string | undefined>; - getMcpServerCommand: Mock<() => string | undefined>; - getMcpServers: Mock<() => Record | undefined>; - getPromptRegistry: Mock<() => Record>; - getExtensions: Mock< - () => Array<{ name: string; version: string; isActive: boolean }> - >; - getBlockedMcpServers: Mock< - () => Array<{ name: string; extensionName: string }> - >; - getUserAgent: Mock<() => string>; - getUserMemory: Mock<() => string>; - setUserMemory: Mock<(newUserMemory: string) => void>; - getGeminiMdFileCount: Mock<() => number>; - setGeminiMdFileCount: Mock<(count: number) => void>; - getApprovalMode: Mock<() => ApprovalMode>; - setApprovalMode: Mock<(skip: ApprovalMode) => void>; - getVertexAI: Mock<() => boolean | undefined>; - getShowMemoryUsage: Mock<() => boolean>; - getAccessibility: Mock<() => AccessibilitySettings>; - getProjectRoot: Mock<() => string | undefined>; - getEnablePromptCompletion: Mock<() => boolean>; - getGeminiClient: Mock<() => GeminiClient | undefined>; - getCheckpointingEnabled: Mock<() => boolean>; - getAllGeminiMdFilenames: Mock<() => string[]>; - setFlashFallbackHandler: Mock<(handler: (fallback: boolean) => void) => void>; - getSessionId: Mock<() => string>; - getUserTier: Mock<() => Promise>; - getIdeMode: Mock<() => boolean>; - getWorkspaceContext: Mock< - () => { - getDirectories: Mock<() => string[]>; - } - >; - getIdeClient: Mock< - () => { - getCurrentIde: Mock<() => string | undefined>; - getDetectedIdeDisplayName: Mock<() => string>; - addStatusChangeListener: Mock< - (listener: (status: string) => void) => void - >; - removeStatusChangeListener: Mock< - (listener: (status: string) => void) => void - >; - getConnectionStatus: Mock<() => string>; - } - >; - isTrustedFolder: Mock<() => boolean>; - getScreenReader: Mock<() => boolean>; -} - -// Mock @qwen-code/qwen-code-core and its Config class -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actualCore = - await importOriginal(); - const ConfigClassMock = vi - .fn() - .mockImplementation((optionsPassedToConstructor) => { - const opts = { ...optionsPassedToConstructor }; // Clone - // Basic mock structure, will be extended by the instance in tests - return { - apiKey: opts.apiKey || 'test-key', - model: opts.model || 'test-model-in-mock-factory', - sandbox: opts.sandbox, - targetDir: opts.targetDir || '/test/dir', - debugMode: opts.debugMode || false, - question: opts.question, - fullContext: opts.fullContext ?? false, - coreTools: opts.coreTools, - toolDiscoveryCommand: opts.toolDiscoveryCommand, - toolCallCommand: opts.toolCallCommand, - mcpServerCommand: opts.mcpServerCommand, - mcpServers: opts.mcpServers, - userAgent: opts.userAgent || 'test-agent', - userMemory: opts.userMemory || '', - geminiMdFileCount: opts.geminiMdFileCount || 0, - approvalMode: opts.approvalMode ?? ApprovalMode.DEFAULT, - vertexai: opts.vertexai, - showMemoryUsage: opts.showMemoryUsage ?? false, - accessibility: opts.accessibility ?? {}, - embeddingModel: opts.embeddingModel || 'test-embedding-model', - - getApiKey: vi.fn(() => opts.apiKey || 'test-key'), - getModel: vi.fn(() => opts.model || 'test-model-in-mock-factory'), - getSandbox: vi.fn(() => opts.sandbox), - getTargetDir: vi.fn(() => opts.targetDir || '/test/dir'), - getToolRegistry: vi.fn(() => ({}) as ToolRegistry), // Simple mock - getDebugMode: vi.fn(() => opts.debugMode || false), - getQuestion: vi.fn(() => opts.question), - getFullContext: vi.fn(() => opts.fullContext ?? false), - getCoreTools: vi.fn(() => opts.coreTools), - getToolDiscoveryCommand: vi.fn(() => opts.toolDiscoveryCommand), - getToolCallCommand: vi.fn(() => opts.toolCallCommand), - getMcpServerCommand: vi.fn(() => opts.mcpServerCommand), - getMcpServers: vi.fn(() => opts.mcpServers), - getPromptRegistry: vi.fn(), - getExtensions: vi.fn(() => []), - getBlockedMcpServers: vi.fn(() => []), - getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'), - getUserMemory: vi.fn(() => opts.userMemory || ''), - setUserMemory: vi.fn(), - getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0), - setGeminiMdFileCount: vi.fn(), - getApprovalMode: vi.fn(() => opts.approvalMode ?? ApprovalMode.DEFAULT), - setApprovalMode: vi.fn(), - getVertexAI: vi.fn(() => opts.vertexai), - getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false), - getAccessibility: vi.fn(() => opts.accessibility ?? {}), - getProjectRoot: vi.fn(() => opts.targetDir), - getEnablePromptCompletion: vi.fn(() => false), - getGeminiClient: vi.fn(() => ({ - getUserTier: vi.fn(), - })), - getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true), - getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']), - setFlashFallbackHandler: vi.fn(), - getSessionId: vi.fn(() => 'test-session-id'), - getUserTier: vi.fn().mockResolvedValue(undefined), - getIdeMode: vi.fn(() => true), - getWorkspaceContext: vi.fn(() => ({ - getDirectories: vi.fn(() => []), - })), - getIdeClient: vi.fn(() => ({ - getCurrentIde: vi.fn(() => 'vscode'), - getDetectedIdeDisplayName: vi.fn(() => 'VSCode'), - addStatusChangeListener: vi.fn(), - removeStatusChangeListener: vi.fn(), - getConnectionStatus: vi.fn(() => 'connected'), - })), - isTrustedFolder: vi.fn(() => true), - getScreenReader: vi.fn(() => false), - }; - }); - - const ideContextMock = { - getIdeContext: vi.fn(), - subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function - }; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Text, useIsScreenReaderEnabled } from 'ink'; +import { App } from './App.js'; +import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { StreamingState } from './types.js'; +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); return { - ...actualCore, - Config: ConfigClassMock, - MCPServerConfig: actualCore.MCPServerConfig, - getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']), - ideContext: ideContextMock, - isGitRepository: vi.fn(), + ...original, + useIsScreenReaderEnabled: vi.fn(), }; }); -// Mock heavy dependencies or those with side effects -vi.mock('./hooks/useGeminiStream', () => ({ - useGeminiStream: vi.fn(() => ({ - streamingState: 'Idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - })), +vi.mock('./components/MainContent.js', () => ({ + MainContent: () => MainContent, })); -vi.mock('./hooks/useAuthCommand', () => ({ - useAuthCommand: vi.fn(() => ({ - isAuthDialogOpen: false, - openAuthDialog: vi.fn(), - handleAuthSelect: vi.fn(), - handleAuthHighlight: vi.fn(), - isAuthenticating: false, - cancelAuthentication: vi.fn(), - })), +vi.mock('./components/DialogManager.js', () => ({ + DialogManager: () => DialogManager, })); -vi.mock('./hooks/useFolderTrust', () => ({ - useFolderTrust: vi.fn(() => ({ - isTrusted: undefined, - isFolderTrustDialogOpen: false, - handleFolderTrustSelect: vi.fn(), - isRestarting: false, - })), +vi.mock('./components/Composer.js', () => ({ + Composer: () => Composer, })); -vi.mock('./hooks/useLogger', () => ({ - useLogger: vi.fn(() => ({ - getPreviousUserMessages: vi.fn().mockResolvedValue([]), - })), +vi.mock('./components/Notifications.js', () => ({ + Notifications: () => Notifications, })); -vi.mock('./hooks/useConsoleMessages.js', () => ({ - useConsoleMessages: vi.fn(() => ({ - consoleMessages: [], - handleNewMessage: vi.fn(), - clearConsoleMessages: vi.fn(), - })), +vi.mock('./components/QuittingDisplay.js', () => ({ + QuittingDisplay: () => Quitting..., })); -vi.mock('../config/config.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - // @ts-expect-error - this is fine - ...actual, - loadHierarchicalGeminiMemory: vi - .fn() - .mockResolvedValue({ memoryContent: '', fileCount: 0 }), - }; -}); - -vi.mock('./components/Tips.js', () => ({ - Tips: vi.fn(() => null), +vi.mock('./components/Footer.js', () => ({ + Footer: () => Footer, })); -vi.mock('./components/Header.js', () => ({ - Header: vi.fn(() => null), -})); - -vi.mock('./utils/updateCheck.js', () => ({ - checkForUpdates: vi.fn(), -})); - -vi.mock('../config/auth.js', () => ({ - validateAuthMethod: vi.fn(), -})); - -vi.mock('../hooks/useTerminalSize.js', () => ({ - useTerminalSize: vi.fn(), -})); - -const mockedCheckForUpdates = vi.mocked(checkForUpdates); -const { isGitRepository: mockedIsGitRepository } = vi.mocked( - await import('@qwen-code/qwen-code-core'), -); - -vi.mock('node:child_process'); - -describe('App UI', () => { - let mockConfig: MockServerConfig; - let mockSettings: LoadedSettings; - let mockVersion: string; - let currentUnmount: (() => void) | undefined; - - const createMockSettings = ( - settings: { - system?: Partial; - user?: Partial; - workspace?: Partial; - } = {}, - ): LoadedSettings => { - const systemSettingsFile: SettingsFile = { - path: '/system/settings.json', - settings: settings.system || {}, - }; - const systemDefaultsFile: SettingsFile = { - path: '/system/system-defaults.json', - settings: {}, - }; - const userSettingsFile: SettingsFile = { - path: '/user/settings.json', - settings: settings.user || {}, - }; - const workspaceSettingsFile: SettingsFile = { - path: '/workspace/.gemini/settings.json', - settings: settings.workspace || {}, - }; - return new LoadedSettings( - systemSettingsFile, - systemDefaultsFile, - userSettingsFile, - workspaceSettingsFile, - [], - true, - new Set(), - ); +describe('App', () => { + const mockUIState: Partial = { + streamingState: StreamingState.Idle, + quittingMessages: null, + dialogsVisible: false, + mainControlsRef: { current: null }, + historyManager: { + addItem: vi.fn(), + history: [], + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }, }; - beforeEach(() => { - vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ - columns: 120, - rows: 24, - }); - - const ServerConfigMocked = vi.mocked(ServerConfig, true); - mockConfig = new ServerConfigMocked({ - embeddingModel: 'test-embedding-model', - sandbox: undefined, - targetDir: '/test/dir', - debugMode: false, - userMemory: '', - geminiMdFileCount: 0, - showMemoryUsage: false, - sessionId: 'test-session-id', - cwd: '/tmp', - model: 'model', - }) as unknown as MockServerConfig; - mockVersion = '0.0.0-test'; - - // Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock - if (!mockConfig.getShowMemoryUsage) { - mockConfig.getShowMemoryUsage = vi.fn(() => false); - } - mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests - - // Ensure a theme is set so the theme dialog does not appear. - mockSettings = createMockSettings({ - workspace: { ui: { theme: 'Default' } }, - }); - - // Ensure getWorkspaceContext is available if not added by the constructor - if (!mockConfig.getWorkspaceContext) { - mockConfig.getWorkspaceContext = vi.fn(() => ({ - getDirectories: vi.fn(() => ['/test/dir']), - })); - } - vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined); - }); - - afterEach(() => { - if (currentUnmount) { - currentUnmount(); - currentUnmount = undefined; - } - vi.clearAllMocks(); // Clear mocks after each test - }); - - describe('handleAutoUpdate', () => { - let spawnEmitter: EventEmitter; - - beforeEach(async () => { - const { spawn } = await import('node:child_process'); - spawnEmitter = new EventEmitter(); - ( - spawnEmitter as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - } - ).stdout = new EventEmitter(); - ( - spawnEmitter as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - } - ).stderr = new EventEmitter(); - (spawn as Mock).mockReturnValue(spawnEmitter); - }); - - afterEach(() => { - delete process.env['GEMINI_CLI_DISABLE_AUTOUPDATER']; - }); - - it('should not start the update process when running from git', async () => { - mockedIsGitRepository.mockResolvedValue(true); - const info: UpdateObject = { - update: { - name: '@qwen-code/qwen-code', - latest: '1.1.0', - current: '1.0.0', - type: 'major' as const, - }, - message: 'Qwen Code update available!', - }; - mockedCheckForUpdates.mockResolvedValue(info); - const { spawn } = await import('node:child_process'); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Wait for any potential async operations to complete - await waitFor(() => { - expect(spawn).not.toHaveBeenCalled(); - }); - }); - - it('should show a success message when update succeeds', async () => { - mockedIsGitRepository.mockResolvedValue(false); - const info: UpdateObject = { - update: { - name: '@qwen-code/qwen-code', - latest: '1.1.0', - current: '1.0.0', - type: 'major' as const, - }, - message: 'Update available', - }; - mockedCheckForUpdates.mockResolvedValue(info); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - updateEventEmitter.emit('update-success', info); - - // Wait for the success message to appear - await waitFor(() => { - expect(lastFrame()).toContain( - 'Update successful! The new version will be used on your next run.', - ); - }); - }); - - it('should show an error message when update fails', async () => { - mockedIsGitRepository.mockResolvedValue(false); - const info: UpdateObject = { - update: { - name: '@qwen-code/qwen-code', - latest: '1.1.0', - current: '1.0.0', - type: 'major' as const, - }, - message: 'Update available', - }; - mockedCheckForUpdates.mockResolvedValue(info); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - updateEventEmitter.emit('update-failed', info); - - // Wait for the error message to appear - await waitFor(() => { - expect(lastFrame()).toContain( - 'Automatic update failed. Please try updating manually', - ); - }); - }); - - it('should show an error message when spawn fails', async () => { - mockedIsGitRepository.mockResolvedValue(false); - const info: UpdateObject = { - update: { - name: '@qwen-code/qwen-code', - latest: '1.1.0', - current: '1.0.0', - type: 'major' as const, - }, - message: 'Update available', - }; - mockedCheckForUpdates.mockResolvedValue(info); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // We are testing the App's reaction to an `update-failed` event, - // which is what should be emitted when a spawn error occurs elsewhere. - updateEventEmitter.emit('update-failed', info); - - // Wait for the error message to appear - await waitFor(() => { - expect(lastFrame()).toContain( - 'Automatic update failed. Please try updating manually', - ); - }); - }); - - it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => { - mockedIsGitRepository.mockResolvedValue(false); - process.env['GEMINI_CLI_DISABLE_AUTOUPDATER'] = 'true'; - const info: UpdateObject = { - update: { - name: '@qwen-code/qwen-code', - latest: '1.1.0', - current: '1.0.0', - type: 'major' as const, - }, - message: 'Update available', - }; - mockedCheckForUpdates.mockResolvedValue(info); - const { spawn } = await import('node:child_process'); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Wait for any potential async operations to complete - await waitFor(() => { - expect(spawn).not.toHaveBeenCalled(); - }); - }); - }); - - it('should display active file when available', async () => { - vi.mocked(ideContext.getIdeContext).mockReturnValue({ - workspaceState: { - openFiles: [ - { - path: '/path/to/my-file.ts', - isActive: true, - selectedText: 'hello', - timestamp: 0, - }, - ], - }, - }); - - const { lastFrame, unmount } = renderWithProviders( - , + it('should render main content and composer when not quitting', () => { + const { lastFrame } = render( + + + , ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('1 open file (ctrl+g to view)'); + + expect(lastFrame()).toContain('MainContent'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Composer'); }); - it('should not display any files when not available', async () => { - vi.mocked(ideContext.getIdeContext).mockReturnValue({ - workspaceState: { - openFiles: [], - }, - }); + it('should render quitting display when quittingMessages is set', () => { + const quittingUIState = { + ...mockUIState, + quittingMessages: [{ id: 1, type: 'user', text: 'test' }], + } as UIState; - const { lastFrame, unmount } = renderWithProviders( - , + const { lastFrame } = render( + + + , ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).not.toContain('Open File'); + + expect(lastFrame()).toContain('Quitting...'); }); - it('should display active file and other open files', async () => { - vi.mocked(ideContext.getIdeContext).mockReturnValue({ - workspaceState: { - openFiles: [ - { - path: '/path/to/my-file.ts', - isActive: true, - selectedText: 'hello', - timestamp: 0, - }, - { - path: '/path/to/another-file.ts', - isActive: false, - timestamp: 1, - }, - { - path: '/path/to/third-file.ts', - isActive: false, - timestamp: 2, - }, - ], - }, - }); + it('should render dialog manager when dialogs are visible', () => { + const dialogUIState = { + ...mockUIState, + dialogsVisible: true, + } as UIState; - const { lastFrame, unmount } = renderWithProviders( - , + const { lastFrame } = render( + + + , ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('3 open files (ctrl+g to view)'); + + expect(lastFrame()).toContain('MainContent'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('DialogManager'); }); - it('should display active file and other context', async () => { - vi.mocked(ideContext.getIdeContext).mockReturnValue({ - workspaceState: { - openFiles: [ - { - path: '/path/to/my-file.ts', - isActive: true, - selectedText: 'hello', - timestamp: 0, - }, - ], - }, - }); - mockConfig.getGeminiMdFileCount.mockReturnValue(1); - mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']); + it('should show Ctrl+C exit prompt when dialogs are visible and ctrlCPressedOnce is true', () => { + const ctrlCUIState = { + ...mockUIState, + dialogsVisible: true, + ctrlCPressedOnce: true, + } as UIState; - const { lastFrame, unmount } = renderWithProviders( - , + const { lastFrame } = render( + + + , ); - currentUnmount = unmount; - await Promise.resolve(); + + expect(lastFrame()).toContain('Press Ctrl+C again to exit.'); + }); + + it('should show Ctrl+D exit prompt when dialogs are visible and ctrlDPressedOnce is true', () => { + const ctrlDUIState = { + ...mockUIState, + dialogsVisible: true, + ctrlDPressedOnce: true, + } as UIState; + + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toContain('Press Ctrl+D again to exit.'); + }); + + it('should render ScreenReaderAppLayout when screen reader is enabled', () => { + (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true); + + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( - 'Using: 1 open file (ctrl+g to view) | 1 QWEN.md file', + 'Notifications\nFooter\nMainContent\nComposer', ); }); - it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => { - mockConfig.getGeminiMdFileCount.mockReturnValue(1); - mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']); - // For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); + it('should render DefaultAppLayout when screen reader is not enabled', () => { + (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false); - const { lastFrame, unmount } = renderWithProviders( - , + const { lastFrame } = render( + + + , ); - currentUnmount = unmount; - await Promise.resolve(); // Wait for any async updates - expect(lastFrame()).toContain('Using: 1 QWEN.md file'); - }); - it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => { - mockConfig.getGeminiMdFileCount.mockReturnValue(2); - mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Using: 2 QWEN.md files'); - }); - - it('should display custom contextFileName in footer when set and count is 1', async () => { - mockSettings = createMockSettings({ - workspace: { - context: { fileName: 'AGENTS.md' }, - ui: { theme: 'Default' }, - }, - }); - mockConfig.getGeminiMdFileCount.mockReturnValue(1); - mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Using: 1 AGENTS.md file'); - }); - - it('should display a generic message when multiple context files with different names are provided', async () => { - mockSettings = createMockSettings({ - workspace: { - context: { fileName: ['AGENTS.md', 'CONTEXT.md'] }, - ui: { theme: 'Default' }, - }, - }); - mockConfig.getGeminiMdFileCount.mockReturnValue(2); - mockConfig.getAllGeminiMdFilenames.mockReturnValue([ - 'AGENTS.md', - 'CONTEXT.md', - ]); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Using: 2 context files'); - }); - - it('should display custom contextFileName with plural when set and count is > 1', async () => { - mockSettings = createMockSettings({ - workspace: { - context: { fileName: 'MY_NOTES.TXT' }, - ui: { theme: 'Default' }, - }, - }); - mockConfig.getGeminiMdFileCount.mockReturnValue(3); - mockConfig.getAllGeminiMdFilenames.mockReturnValue([ - 'MY_NOTES.TXT', - 'MY_NOTES.TXT', - 'MY_NOTES.TXT', - ]); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Using: 3 MY_NOTES.TXT files'); - }); - - it('should not display context file message if count is 0, even if contextFileName is set', async () => { - mockSettings = createMockSettings({ - workspace: { - context: { fileName: 'ANY_FILE.MD' }, - ui: { theme: 'Default' }, - }, - }); - mockConfig.getGeminiMdFileCount.mockReturnValue(0); - mockConfig.getAllGeminiMdFilenames.mockReturnValue([]); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).not.toContain('ANY_FILE.MD'); - }); - - it('should display QWEN.md and MCP server count when both are present', async () => { - mockConfig.getGeminiMdFileCount.mockReturnValue(2); - mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']); - mockConfig.getMcpServers.mockReturnValue({ - server1: {} as MCPServerConfig, - }); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('1 MCP server'); - }); - - it('should display only MCP server count when QWEN.md count is 0', async () => { - mockConfig.getGeminiMdFileCount.mockReturnValue(0); - mockConfig.getAllGeminiMdFilenames.mockReturnValue([]); - mockConfig.getMcpServers.mockReturnValue({ - server1: {} as MCPServerConfig, - server2: {} as MCPServerConfig, - }); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Using: 2 MCP servers (ctrl+t to view)'); - }); - - it('should display Tips component by default', async () => { - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(vi.mocked(Tips)).toHaveBeenCalled(); - }); - - it('should not display Tips component when hideTips is true', async () => { - mockSettings = createMockSettings({ - workspace: { - ui: { hideTips: true }, - }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(vi.mocked(Tips)).not.toHaveBeenCalled(); - }); - - it('should display Header component by default', async () => { - const { Header } = await import('./components/Header.js'); - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(vi.mocked(Header)).toHaveBeenCalled(); - }); - - it('should not display Header component when hideBanner is true', async () => { - const { Header } = await import('./components/Header.js'); - mockSettings = createMockSettings({ - user: { ui: { hideBanner: true } }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(vi.mocked(Header)).not.toHaveBeenCalled(); - }); - - it('should display Footer component by default', async () => { - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - // Footer should render - look for target directory which is always shown - expect(lastFrame()).toContain('/test/dir'); - }); - - it('should not display Footer component when hideFooter is true', async () => { - mockSettings = createMockSettings({ - user: { ui: { hideFooter: true } }, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - // Footer should not render - target directory should not appear - expect(lastFrame()).not.toContain('/test/dir'); - }); - - it('should show footer if system says show, but workspace and user settings say hide', async () => { - mockSettings = createMockSettings({ - system: { ui: { hideFooter: false } }, - user: { ui: { hideFooter: true } }, - workspace: { ui: { hideFooter: true } }, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - // Footer should render because system overrides - look for target directory - expect(lastFrame()).toContain('/test/dir'); - }); - - it('should show tips if system says show, but workspace and user settings say hide', async () => { - mockSettings = createMockSettings({ - system: { ui: { hideTips: false } }, - user: { ui: { hideTips: true } }, - workspace: { ui: { hideTips: true } }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(vi.mocked(Tips)).toHaveBeenCalled(); - }); - - describe('when no theme is set', () => { - let originalNoColor: string | undefined; - - beforeEach(() => { - originalNoColor = process.env['NO_COLOR']; - // Ensure no theme is set for these tests - mockSettings = createMockSettings({}); - mockConfig.getDebugMode.mockReturnValue(false); - mockConfig.getShowMemoryUsage.mockReturnValue(false); - }); - - afterEach(() => { - process.env['NO_COLOR'] = originalNoColor; - }); - - it('should display theme dialog if NO_COLOR is not set', async () => { - delete process.env['NO_COLOR']; - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel"); - }); - - it('should display a message if NO_COLOR is set', async () => { - process.env['NO_COLOR'] = 'true'; - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel"); - expect(lastFrame()).not.toContain('Select Theme'); - }); - }); - - it('should render the initial UI correctly', () => { - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - expect(lastFrame()).toMatchSnapshot(); - }); - - it('should render correctly with the prompt input box', () => { - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - expect(lastFrame()).toMatchSnapshot(); - }); - - describe('with initial prompt from --prompt-interactive', () => { - it('should submit the initial prompt automatically', async () => { - const mockSubmitQuery = vi.fn(); - - mockConfig.getQuestion = vi.fn(() => 'hello from prompt-interactive'); - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - mockConfig.getGeminiClient.mockReturnValue({ - isInitialized: vi.fn(() => true), - getUserTier: vi.fn(), - } as unknown as GeminiClient); - - const { unmount, rerender } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Force a re-render to trigger useEffect - rerender( - , - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockSubmitQuery).toHaveBeenCalledWith( - 'hello from prompt-interactive', - ); - }); - }); - - describe('errorCount', () => { - it('should correctly sum the counts of error messages', async () => { - const mockConsoleMessages: ConsoleMessageItem[] = [ - { type: 'error', content: 'First error', count: 1 }, - { type: 'log', content: 'some log', count: 1 }, - { type: 'error', content: 'Second error', count: 3 }, - { type: 'warn', content: 'a warning', count: 1 }, - { type: 'error', content: 'Third error', count: 1 }, - ]; - - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: mockConsoleMessages, - handleNewMessage: vi.fn(), - clearConsoleMessages: vi.fn(), - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - - // Total error count should be 1 + 3 + 1 = 5 - expect(lastFrame()).toContain('5 errors'); - }); - }); - - describe('auth validation', () => { - it('should call validateAuthMethod when useExternalAuth is false', async () => { - const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); - mockSettings = createMockSettings({ - workspace: { - security: { - auth: { - selectedType: 'USE_GEMINI' as AuthType, - useExternal: false, - }, - }, - ui: { theme: 'Default' }, - }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI'); - }); - - it('should NOT call validateAuthMethod when useExternalAuth is true', async () => { - const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); - mockSettings = createMockSettings({ - workspace: { - security: { - auth: { - selectedType: 'USE_GEMINI' as AuthType, - useExternal: true, - }, - }, - ui: { theme: 'Default' }, - }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(validateAuthMethodSpy).not.toHaveBeenCalled(); - }); - }); - - describe('when in a narrow terminal', () => { - it('should render with a column layout', () => { - vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ - columns: 60, - rows: 24, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - expect(lastFrame()).toMatchSnapshot(); - }); - }); - - describe('NO_COLOR smoke test', () => { - let originalNoColor: string | undefined; - - beforeEach(() => { - originalNoColor = process.env['NO_COLOR']; - }); - - afterEach(() => { - process.env['NO_COLOR'] = originalNoColor; - }); - - it('should render without errors when NO_COLOR is set', async () => { - process.env['NO_COLOR'] = 'true'; - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(lastFrame()).toBeTruthy(); - expect(lastFrame()).toContain('Type your message or @path/to/file'); - }); - }); - - describe('FolderTrustDialog', () => { - it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => { - const { useFolderTrust } = await import('./hooks/useFolderTrust.js'); - vi.mocked(useFolderTrust).mockReturnValue({ - isTrusted: undefined, - isFolderTrustDialogOpen: true, - handleFolderTrustSelect: vi.fn(), - isRestarting: false, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Do you trust this folder?'); - }); - - it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => { - const { useFolderTrust } = await import('./hooks/useFolderTrust.js'); - vi.mocked(useFolderTrust).mockReturnValue({ - isTrusted: false, - isFolderTrustDialogOpen: true, - handleFolderTrustSelect: vi.fn(), - isRestarting: false, - }); - mockConfig.isTrustedFolder.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).toContain('Do you trust this folder?'); - }); - - it('should not display the folder trust dialog when the feature is disabled', async () => { - const { useFolderTrust } = await import('./hooks/useFolderTrust.js'); - vi.mocked(useFolderTrust).mockReturnValue({ - isTrusted: false, - isFolderTrustDialogOpen: false, - handleFolderTrustSelect: vi.fn(), - isRestarting: false, - }); - mockConfig.isTrustedFolder.mockReturnValue(false); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - await Promise.resolve(); - expect(lastFrame()).not.toContain('Do you trust this folder?'); - }); - }); - - describe('Message Queuing', () => { - let mockSubmitQuery: Mock; - - beforeEach(() => { - mockSubmitQuery = vi.fn(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should queue messages when handleFinalSubmit is called during streaming', () => { - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // The message should not be sent immediately during streaming - expect(mockSubmitQuery).not.toHaveBeenCalled(); - }); - - it('should auto-send queued messages when transitioning from Responding to Idle', async () => { - const mockSubmitQueryFn = vi.fn(); - - // Start with Responding state - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQueryFn, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount, rerender } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Simulate the hook returning Idle state (streaming completed) - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQueryFn, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - // Rerender to trigger the useEffect with new state - rerender( - , - ); - - // The effect uses setTimeout(100ms) before sending - await vi.advanceTimersByTimeAsync(100); - - // Note: In the actual implementation, messages would be queued first - // This test verifies the auto-send mechanism works when state transitions - }); - - it('should display queued messages with dimmed color', () => { - // This test would require being able to simulate handleFinalSubmit - // and then checking the rendered output for the queued messages - // with the ▸ prefix and dimColor styling - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: { subject: 'Processing', description: 'Processing...' }, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount, lastFrame } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // The actual queued messages display is tested visually - // since we need to trigger handleFinalSubmit which is internal - const output = lastFrame(); - expect(output).toBeDefined(); - }); - - it('should clear message queue after sending', async () => { - const mockSubmitQueryFn = vi.fn(); - - // Start with idle to allow message queue to process - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQueryFn, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount, lastFrame } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // After sending, the queue should be cleared - // This is handled internally by setMessageQueue([]) in the useEffect - await vi.advanceTimersByTimeAsync(100); - - // Verify the component renders without errors - expect(lastFrame()).toBeDefined(); - }); - - it('should handle empty messages by filtering them out', () => { - // The handleFinalSubmit function trims and checks if length > 0 - // before adding to queue, so empty messages are filtered - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Empty or whitespace-only messages won't be added to queue - // This is enforced by the trimmedValue.length > 0 check - expect(mockSubmitQuery).not.toHaveBeenCalled(); - }); - - it('should combine multiple queued messages with double newlines', async () => { - // This test verifies that when multiple messages are queued, - // they are combined with '\n\n' as the separator - - const mockSubmitQueryFn = vi.fn(); - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQueryFn, - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); - - const { unmount, lastFrame } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // The combining logic uses messageQueue.join('\n\n') - // This is tested by the implementation in the useEffect - await vi.advanceTimersByTimeAsync(100); - - expect(lastFrame()).toBeDefined(); - }); - - it('should limit displayed messages to MAX_DISPLAYED_QUEUED_MESSAGES', () => { - // This test verifies the display logic handles multiple messages correctly - // by checking that the MAX_DISPLAYED_QUEUED_MESSAGES constant is respected - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: { subject: 'Processing', description: 'Processing...' }, - cancelOngoingRequest: vi.fn(), - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - const output = lastFrame(); - - // Verify the display logic exists and can handle multiple messages - // The actual queue behavior is tested in the useMessageQueue hook tests - expect(output).toBeDefined(); - - // Check that the component renders without errors when there are messages to display - expect(output).not.toContain('Error'); - }); - - it('should render message queue display without errors', () => { - // Test that the message queue display logic renders correctly - // This verifies the UI changes for performance improvements work - - vi.mocked(useGeminiStream).mockReturnValue({ - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - initError: null, - pendingHistoryItems: [], - thought: { subject: 'Processing', description: 'Processing...' }, - cancelOngoingRequest: vi.fn(), - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - const output = lastFrame(); - - // Verify component renders without errors - expect(output).toBeDefined(); - expect(output).not.toContain('Error'); - - // Verify the component structure is intact (loading indicator should be present) - expect(output).toContain('esc to cancel'); - }); - }); - - describe('debug keystroke logging', () => { - let consoleLogSpy: ReturnType; - - beforeEach(() => { - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); - }); - - it('should pass debugKeystrokeLogging setting to KeypressProvider', () => { - const mockSettingsWithDebug = createMockSettings({ - workspace: { - ui: { theme: 'Default' }, - general: { debugKeystrokeLogging: true }, - }, - }); - - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - const output = lastFrame(); - - expect(output).toBeDefined(); - expect(mockSettingsWithDebug.merged.general?.debugKeystrokeLogging).toBe( - true, - ); - }); - - it('should use default false value when debugKeystrokeLogging is not set', () => { - const { lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - const output = lastFrame(); - - expect(output).toBeDefined(); - expect( - mockSettings.merged.general?.debugKeystrokeLogging, - ).toBeUndefined(); - }); - }); - - describe('Ctrl+C behavior', () => { - it('should call cancel but only clear the prompt when a tool is executing', async () => { - const mockCancel = vi.fn(); - let onCancelSubmitCallback = () => {}; - - // Simulate a tool in the "Executing" state. - vi.mocked(useGeminiStream).mockImplementation( - ( - _client, - _history, - _addItem, - _config, - _onDebugMessage, - _handleSlashCommand, - _shellModeActive, - _getPreferredEditor, - _onAuthError, - _performMemoryRefresh, - _modelSwitchedFromQuotaError, - _setModelSwitchedFromQuotaError, - _onEditorClose, - onCancelSubmit, // Capture the cancel callback from App.tsx - ) => { - onCancelSubmitCallback = onCancelSubmit; - return { - streamingState: StreamingState.Responding, - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [ - { - type: 'tool_group', - tools: [ - { - name: 'test_tool', - status: ToolCallStatus.Executing, - callId: 'test-call-id', - description: 'Test tool description', - resultDisplay: 'Test result', - confirmationDetails: undefined, - }, - ], - }, - ], - thought: null, - cancelOngoingRequest: () => { - mockCancel(); - onCancelSubmitCallback(); // <--- This is the key change - }, - }; - }, - ); - - const { stdin, lastFrame, unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - // Simulate user typing something into the prompt while a tool is running. - stdin.write('some text'); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the text is in the prompt. - expect(lastFrame()).toContain('some text'); - - // Simulate Ctrl+C. - stdin.write('\x03'); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // The main cancellation handler SHOULD be called. - expect(mockCancel).toHaveBeenCalled(); - - // The prompt should now be empty as a result of the cancellation handler's logic. - // We can't directly test the buffer's state, but we can see the rendered output. - await waitFor(() => { - expect(lastFrame()).not.toContain('some text'); - }); - }); + expect(lastFrame()).toContain('MainContent\nNotifications\nComposer'); }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 1597c765..ea8482a1 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -4,1653 +4,47 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { - Box, - type DOMElement, - measureElement, - Static, - Text, - useStdin, - useStdout, -} from 'ink'; -import { - StreamingState, - type HistoryItem, - MessageType, - ToolCallStatus, - type HistoryItemWithoutId, -} from './types.js'; +import { useIsScreenReaderEnabled } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; -import { useGeminiStream } from './hooks/useGeminiStream.js'; -import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; -import { useThemeCommand } from './hooks/useThemeCommand.js'; -import { useAuthCommand } from './hooks/useAuthCommand.js'; -import { useQwenAuth } from './hooks/useQwenAuth.js'; -import { useFolderTrust } from './hooks/useFolderTrust.js'; -import { useEditorSettings } from './hooks/useEditorSettings.js'; -import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; -import { useWelcomeBack } from './hooks/useWelcomeBack.js'; -import { useDialogClose } from './hooks/useDialogClose.js'; -import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; -import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; -import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; -import { Header } from './components/Header.js'; -import { LoadingIndicator } from './components/LoadingIndicator.js'; -import { AutoAcceptIndicator } from './components/AutoAcceptIndicator.js'; -import { ShellModeIndicator } from './components/ShellModeIndicator.js'; -import { InputPrompt } from './components/InputPrompt.js'; -import { Footer } from './components/Footer.js'; -import { ThemeDialog } from './components/ThemeDialog.js'; -import { AuthDialog } from './components/AuthDialog.js'; -import { AuthInProgress } from './components/AuthInProgress.js'; -import { QwenOAuthProgress } from './components/QwenOAuthProgress.js'; -import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; -import { FolderTrustDialog } from './components/FolderTrustDialog.js'; -import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; -import { QuitConfirmationDialog } from './components/QuitConfirmationDialog.js'; -import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; -import { ModelSelectionDialog } from './components/ModelSelectionDialog.js'; -import { - ModelSwitchDialog, - type VisionSwitchOutcome, -} from './components/ModelSwitchDialog.js'; -import { - getOpenAIAvailableModelFromEnv, - getFilteredQwenModels, - type AvailableModel, -} from './models/availableModels.js'; -import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; -import { - AgentCreationWizard, - AgentsManagerDialog, -} from './components/subagents/index.js'; -import { Colors } from './colors.js'; -import { loadHierarchicalGeminiMemory } from '../config/config.js'; -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope } from '../config/settings.js'; -import { Tips } from './components/Tips.js'; -import { ConsolePatcher } from './utils/ConsolePatcher.js'; -import { registerCleanup } from '../utils/cleanup.js'; -import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js'; -import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; -import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; -import { useHistory } from './hooks/useHistoryManager.js'; -import process from 'node:process'; -import type { EditorType, Config, IdeContext } from '@qwen-code/qwen-code-core'; -import { - ApprovalMode, - getAllGeminiMdFilenames, - isEditorAvailable, - getErrorMessage, - AuthType, - logFlashFallback, - FlashFallbackEvent, - ideContext, - isProQuotaExceededError, - isGenericQuotaExceededError, - UserTierId, -} from '@qwen-code/qwen-code-core'; -import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; -import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { validateAuthMethod } from '../config/auth.js'; -import { useLogger } from './hooks/useLogger.js'; +import { lerp } from '../utils/math.js'; +import { useUIState } from './contexts/UIStateContext.js'; import { StreamingContext } from './contexts/StreamingContext.js'; -import { - SessionStatsProvider, - useSessionStats, -} from './contexts/SessionContext.js'; -import { useGitBranchName } from './hooks/useGitBranchName.js'; -import { useFocus } from './hooks/useFocus.js'; -import { useBracketedPaste } from './hooks/useBracketedPaste.js'; -import { useTextBuffer } from './components/shared/text-buffer.js'; -import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js'; -import { useVim } from './hooks/vim.js'; -import type { Key } from './hooks/useKeypress.js'; -import { useKeypress } from './hooks/useKeypress.js'; -import { KeypressProvider } from './contexts/KeypressContext.js'; -import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js'; -import { keyMatchers, Command } from './keyMatchers.js'; -import * as fs from 'node:fs'; -import { UpdateNotification } from './components/UpdateNotification.js'; -import type { UpdateObject } from './utils/updateCheck.js'; -import ansiEscapes from 'ansi-escapes'; -import { OverflowProvider } from './contexts/OverflowContext.js'; -import { ShowMoreLines } from './components/ShowMoreLines.js'; -import { PrivacyNotice } from './privacy/PrivacyNotice.js'; -import { useSettingsCommand } from './hooks/useSettingsCommand.js'; -import { SettingsDialog } from './components/SettingsDialog.js'; -import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; -import { appEvents, AppEvent } from '../utils/events.js'; -import { isNarrowWidth } from './utils/isNarrowWidth.js'; -import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; -import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js'; -import { WelcomeBackDialog } from './components/WelcomeBackDialog.js'; - -// Maximum number of queued messages to display in UI to prevent performance issues -const MAX_DISPLAYED_QUEUED_MESSAGES = 3; - -interface AppProps { - config: Config; - settings: LoadedSettings; - startupWarnings?: string[]; - version: string; -} - -function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { - return pendingHistoryItems.some((item) => { - if (item && item.type === 'tool_group') { - return item.tools.some( - (tool) => ToolCallStatus.Executing === tool.status, - ); - } - return false; - }); -} - -export const AppWrapper = (props: AppProps) => { - const kittyProtocolStatus = useKittyKeyboardProtocol(); - const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); - return ( - - - - - - - - ); -}; - -const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { - const isFocused = useFocus(); - useBracketedPaste(); - const [updateInfo, setUpdateInfo] = useState(null); - const { stdout } = useStdout(); - const nightly = version.includes('nightly'); - const { history, addItem, clearItems, loadHistory } = useHistory(); - - const [idePromptAnswered, setIdePromptAnswered] = useState(false); - const currentIDE = config.getIdeClient().getCurrentIde(); - useEffect(() => { - registerCleanup(() => config.getIdeClient().disconnect()); - }, [config]); - const shouldShowIdePrompt = - currentIDE && - !config.getIdeMode() && - !settings.merged.ide?.hasSeenNudge && - !idePromptAnswered; - - useEffect(() => { - const cleanup = setUpdateHandler(addItem, setUpdateInfo); - return cleanup; - }, [addItem]); - - const { - consoleMessages, - handleNewMessage, - clearConsoleMessages: clearConsoleMessagesState, - } = useConsoleMessages(); - - useEffect(() => { - const consolePatcher = new ConsolePatcher({ - onNewMessage: handleNewMessage, - debugMode: config.getDebugMode(), - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - }, [handleNewMessage, config]); - - const { stats: sessionStats } = useSessionStats(); - const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false); - const [staticKey, setStaticKey] = useState(0); - const refreshStatic = useCallback(() => { - stdout.write(ansiEscapes.clearTerminal); - setStaticKey((prev) => prev + 1); - }, [setStaticKey, stdout]); - - const [geminiMdFileCount, setGeminiMdFileCount] = useState(0); - const [debugMessage, setDebugMessage] = useState(''); - const [themeError, setThemeError] = useState(null); - const [authError, setAuthError] = useState(null); - const [editorError, setEditorError] = useState(null); - const [footerHeight, setFooterHeight] = useState(0); - const [corgiMode, setCorgiMode] = useState(false); - const [isTrustedFolderState, setIsTrustedFolder] = useState( - config.isTrustedFolder(), - ); - const [currentModel, setCurrentModel] = useState(config.getModel()); - const [shellModeActive, setShellModeActive] = useState(false); - const [showErrorDetails, setShowErrorDetails] = useState(false); - const [showToolDescriptions, setShowToolDescriptions] = - useState(false); - - const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); - const [quittingMessages, setQuittingMessages] = useState< - HistoryItem[] | null - >(null); - const ctrlCTimerRef = useRef(null); - const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); - const ctrlDTimerRef = useRef(null); - const [constrainHeight, setConstrainHeight] = useState(true); - const [showPrivacyNotice, setShowPrivacyNotice] = useState(false); - const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = - useState(false); - const [userTier, setUserTier] = useState(undefined); - const [ideContextState, setIdeContextState] = useState< - IdeContext | undefined - >(); - const [showEscapePrompt, setShowEscapePrompt] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const { - showWorkspaceMigrationDialog, - workspaceExtensions, - onWorkspaceMigrationDialogOpen, - onWorkspaceMigrationDialogClose, - } = useWorkspaceMigration(settings); - - // Model selection dialog states - const [isModelSelectionDialogOpen, setIsModelSelectionDialogOpen] = - useState(false); - const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = - useState(false); - const [visionSwitchResolver, setVisionSwitchResolver] = useState<{ - resolve: (result: { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }) => void; - reject: () => void; - } | null>(null); - - useEffect(() => { - const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState); - // Set the initial value - setIdeContextState(ideContext.getIdeContext()); - return unsubscribe; - }, []); - - useEffect(() => { - const openDebugConsole = () => { - setShowErrorDetails(true); - setConstrainHeight(false); // Make sure the user sees the full message. - }; - appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole); - - const logErrorHandler = (errorMessage: unknown) => { - handleNewMessage({ - type: 'error', - content: String(errorMessage), - count: 1, - }); - }; - appEvents.on(AppEvent.LogError, logErrorHandler); - - return () => { - appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); - appEvents.off(AppEvent.LogError, logErrorHandler); - }; - }, [handleNewMessage]); - - const openPrivacyNotice = useCallback(() => { - setShowPrivacyNotice(true); - }, []); - - const handleEscapePromptChange = useCallback((showPrompt: boolean) => { - setShowEscapePrompt(showPrompt); - }, []); - - const initialPromptSubmitted = useRef(false); - - const errorCount = useMemo( - () => - consoleMessages - .filter((msg) => msg.type === 'error') - .reduce((total, msg) => total + msg.count, 0), - [consoleMessages], - ); - - const { - isThemeDialogOpen, - openThemeDialog, - handleThemeSelect, - handleThemeHighlight, - } = useThemeCommand(settings, setThemeError, addItem); - - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = - useSettingsCommand(); - - const { - isSubagentCreateDialogOpen, - openSubagentCreateDialog, - closeSubagentCreateDialog, - } = useSubagentCreateDialog(); - - const { - isAgentsManagerDialogOpen, - openAgentsManagerDialog, - closeAgentsManagerDialog, - } = useAgentsManagerDialog(); - - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder); - - const { showQuitConfirmation, handleQuitConfirmationSelect } = - useQuitConfirmation(); - - const { - isAuthDialogOpen, - openAuthDialog, - handleAuthSelect, - isAuthenticating, - cancelAuthentication, - } = useAuthCommand(settings, setAuthError, config); - - const { - isQwenAuthenticating, - deviceAuth, - isQwenAuth, - cancelQwenAuth, - authStatus, - authMessage, - } = useQwenAuth(settings, isAuthenticating); - - useEffect(() => { - if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - const error = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (error) { - setAuthError(error); - openAuthDialog(); - } - } - }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - openAuthDialog, - setAuthError, - ]); - - // Sync user tier from config when authentication changes - useEffect(() => { - // Only sync when not currently authenticating - if (!isAuthenticating) { - setUserTier(config.getGeminiClient()?.getUserTier()); - } - }, [config, isAuthenticating]); - - // Handle Qwen OAuth timeout - useEffect(() => { - if (isQwenAuth && authStatus === 'timeout') { - setAuthError( - authMessage || - 'Qwen OAuth authentication timed out. Please try again or select a different authentication method.', - ); - cancelQwenAuth(); - cancelAuthentication(); - openAuthDialog(); - } - }, [ - isQwenAuth, - authStatus, - authMessage, - cancelQwenAuth, - cancelAuthentication, - openAuthDialog, - setAuthError, - ]); - - const { - isEditorDialogOpen, - openEditorDialog, - handleEditorSelect, - exitEditorDialog, - } = useEditorSettings(settings, setEditorError, addItem); - - const toggleCorgiMode = useCallback(() => { - setCorgiMode((prev) => !prev); - }, []); - - const performMemoryRefresh = useCallback(async () => { - addItem( - { - type: MessageType.INFO, - text: 'Refreshing hierarchical memory (QWEN.md or other context files)...', - }, - Date.now(), - ); - try { - const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( - process.cwd(), - settings.merged.context?.loadMemoryFromIncludeDirectories - ? config.getWorkspaceContext().getDirectories() - : [], - config.getDebugMode(), - config.getFileService(), - settings.merged, - config.getExtensionContextFilePaths(), - settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' - config.getFileFilteringOptions(), - ); - - config.setUserMemory(memoryContent); - config.setGeminiMdFileCount(fileCount); - setGeminiMdFileCount(fileCount); - - addItem( - { - type: MessageType.INFO, - text: `Memory refreshed successfully. ${memoryContent.length > 0 ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` : 'No memory content found.'}`, - }, - Date.now(), - ); - if (config.getDebugMode()) { - console.log( - `[DEBUG] Refreshed memory content in config: ${memoryContent.substring(0, 200)}...`, - ); - } - } catch (error) { - const errorMessage = getErrorMessage(error); - addItem( - { - type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, - }, - Date.now(), - ); - console.error('Error refreshing memory:', error); - } - }, [config, addItem, settings.merged]); - - // Watch for model changes (e.g., from Flash fallback) - useEffect(() => { - const checkModelChange = () => { - const configModel = config.getModel(); - if (configModel !== currentModel) { - setCurrentModel(configModel); - } - }; - - // Check immediately and then periodically - checkModelChange(); - const interval = setInterval(checkModelChange, 1000); // Check every second - - return () => clearInterval(interval); - }, [config, currentModel]); - - // Set up Flash fallback handler - useEffect(() => { - const flashFallbackHandler = async ( - currentModel: string, - fallbackModel: string, - error?: unknown, - ): Promise => { - let message: string; - - if ( - config.getContentGeneratorConfig().authType === - AuthType.LOGIN_WITH_GOOGLE - ) { - // Use actual user tier if available; otherwise, default to FREE tier behavior (safe default) - const isPaidTier = - userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD; - - // Check if this is a Pro quota exceeded error - if (error && isProQuotaExceededError(error)) { - if (isPaidTier) { - message = `⚡ You have reached your daily ${currentModel} quota limit. -⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session. -⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - message = `⚡ You have reached your daily ${currentModel} quota limit. -⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session. -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } else if (error && isGenericQuotaExceededError(error)) { - if (isPaidTier) { - message = `⚡ You have reached your daily quota limit. -⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session. -⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - message = `⚡ You have reached your daily quota limit. -⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session. -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } else { - if (isPaidTier) { - // Default fallback message for other cases (like consecutive 429s) - message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session. -⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit -⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - // Default fallback message for other cases (like consecutive 429s) - message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session. -⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } - - // Add message to UI history - addItem( - { - type: MessageType.INFO, - text: message, - }, - Date.now(), - ); - - // Set the flag to prevent tool continuation - setModelSwitchedFromQuotaError(true); - // Set global quota error flag to prevent Flash model calls - config.setQuotaErrorOccurred(true); - } - - // Switch model for future use but return false to stop current retry - config.setModel(fallbackModel).catch((error) => { - console.error('Failed to switch to fallback model:', error); - }); - config.setFallbackMode(true); - logFlashFallback( - config, - new FlashFallbackEvent(config.getContentGeneratorConfig().authType!), - ); - return false; // Don't continue with current prompt - }; - - config.setFlashFallbackHandler(flashFallbackHandler); - }, [config, addItem, userTier]); - - // Terminal and UI setup - const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); - const isNarrow = isNarrowWidth(terminalWidth); - const { stdin, setRawMode } = useStdin(); - const isInitialMount = useRef(true); - - const widthFraction = 0.9; - const inputWidth = Math.max( - 20, - Math.floor(terminalWidth * widthFraction) - 3, - ); - const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8)); - - // Utility callbacks - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); - - const getPreferredEditor = useCallback(() => { - const editorType = settings.merged.general?.preferredEditor; - const isValidEditor = isEditorAvailable(editorType); - if (!isValidEditor) { - openEditorDialog(); - return; - } - return editorType as EditorType; - }, [settings, openEditorDialog]); - - const onAuthError = useCallback(() => { - setAuthError('reauth required'); - openAuthDialog(); - }, [openAuthDialog, setAuthError]); - - // Vision switch handler for auto-switch functionality - const handleVisionSwitchRequired = useCallback( - async (_query: unknown) => - new Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>((resolve, reject) => { - setVisionSwitchResolver({ resolve, reject }); - setIsVisionSwitchDialogOpen(true); - }), - [], - ); - - const handleVisionSwitchSelect = useCallback( - (outcome: VisionSwitchOutcome) => { - setIsVisionSwitchDialogOpen(false); - if (visionSwitchResolver) { - const result = processVisionSwitchOutcome(outcome); - visionSwitchResolver.resolve(result); - setVisionSwitchResolver(null); - } - }, - [visionSwitchResolver], - ); - - const handleModelSelectionOpen = useCallback(() => { - setIsModelSelectionDialogOpen(true); - }, []); - - const handleModelSelectionClose = useCallback(() => { - setIsModelSelectionDialogOpen(false); - }, []); - - const handleModelSelect = useCallback( - async (modelId: string) => { - try { - await config.setModel(modelId); - setCurrentModel(modelId); - setIsModelSelectionDialogOpen(false); - addItem( - { - type: MessageType.INFO, - text: `Switched model to \`${modelId}\` for this session.`, - }, - Date.now(), - ); - } catch (error) { - console.error('Failed to switch model:', error); - addItem( - { - type: MessageType.ERROR, - text: `Failed to switch to model \`${modelId}\`. Please try again.`, - }, - Date.now(), - ); - } - }, - [config, setCurrentModel, addItem], - ); - - const getAvailableModelsForCurrentAuth = useCallback((): AvailableModel[] => { - const contentGeneratorConfig = config.getContentGeneratorConfig(); - if (!contentGeneratorConfig) return []; - - const visionModelPreviewEnabled = - settings.merged.experimental?.visionModelPreview ?? true; - - switch (contentGeneratorConfig.authType) { - case AuthType.QWEN_OAUTH: - return getFilteredQwenModels(visionModelPreviewEnabled); - case AuthType.USE_OPENAI: { - const openAIModel = getOpenAIAvailableModelFromEnv(); - return openAIModel ? [openAIModel] : []; - } - default: - return []; - } - }, [config, settings.merged.experimental?.visionModelPreview]); - - // Core hooks and processors - const { - vimEnabled: vimModeEnabled, - vimMode, - toggleVimEnabled, - } = useVimMode(); - - const { - handleSlashCommand, - slashCommands, - pendingHistoryItems: pendingSlashCommandHistoryItems, - commandContext, - shellConfirmationRequest, - confirmationRequest, - quitConfirmationRequest, - } = useSlashCommandProcessor( - config, - settings, - addItem, - clearItems, - loadHistory, - refreshStatic, - setDebugMessage, - openThemeDialog, - openAuthDialog, - openEditorDialog, - toggleCorgiMode, - setQuittingMessages, - openPrivacyNotice, - openSettingsDialog, - handleModelSelectionOpen, - openSubagentCreateDialog, - openAgentsManagerDialog, - toggleVimEnabled, - setIsProcessing, - setGeminiMdFileCount, - showQuitConfirmation, - ); - - const buffer = useTextBuffer({ - initialText: '', - viewport: { height: 10, width: inputWidth }, - stdin, - setRawMode, - isValidPath, - shellModeActive, - }); - - const [userMessages, setUserMessages] = useState([]); - - // Stable reference for cancel handler to avoid circular dependency - const cancelHandlerRef = useRef<() => void>(() => {}); - - const { - streamingState, - submitQuery, - initError, - pendingHistoryItems: pendingGeminiHistoryItems, - thought, - cancelOngoingRequest, - } = useGeminiStream( - config.getGeminiClient(), - history, - addItem, - config, - setDebugMessage, - handleSlashCommand, - shellModeActive, - getPreferredEditor, - onAuthError, - performMemoryRefresh, - modelSwitchedFromQuotaError, - setModelSwitchedFromQuotaError, - refreshStatic, - () => cancelHandlerRef.current(), - settings.merged.experimental?.visionModelPreview ?? true, - handleVisionSwitchRequired, - ); - - const pendingHistoryItems = useMemo( - () => - [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems].map( - (item, index) => ({ - ...item, - id: index, - }), - ), - [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], - ); - - // Welcome back functionality - const { - welcomeBackInfo, - showWelcomeBackDialog, - welcomeBackChoice, - handleWelcomeBackSelection, - handleWelcomeBackClose, - } = useWelcomeBack(config, submitQuery, buffer, settings.merged); - - // Dialog close functionality - const { closeAnyOpenDialog } = useDialogClose({ - isThemeDialogOpen, - handleThemeSelect, - isAuthDialogOpen, - handleAuthSelect, - selectedAuthType: settings.merged.security?.auth?.selectedType, - isEditorDialogOpen, - exitEditorDialog, - isSettingsDialogOpen, - closeSettingsDialog, - isFolderTrustDialogOpen, - showPrivacyNotice, - setShowPrivacyNotice, - showWelcomeBackDialog, - handleWelcomeBackClose, - quitConfirmationRequest, - }); - - // Message queue for handling input during streaming - const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = - useMessageQueue({ - streamingState, - submitQuery, - }); - - // Update the cancel handler with message queue support - cancelHandlerRef.current = useCallback(() => { - if (isToolExecuting(pendingHistoryItems)) { - buffer.setText(''); // Just clear the prompt - return; - } - - const lastUserMessage = userMessages.at(-1); - let textToSet = lastUserMessage || ''; - - // Append queued messages if any exist - const queuedText = getQueuedMessagesText(); - if (queuedText) { - textToSet = textToSet ? `${textToSet}\n\n${queuedText}` : queuedText; - clearQueue(); - } - - if (textToSet) { - buffer.setText(textToSet); - } - }, [ - buffer, - userMessages, - getQueuedMessagesText, - clearQueue, - pendingHistoryItems, - ]); - - // Input handling - queue messages for processing - const handleFinalSubmit = useCallback( - (submittedValue: string) => { - addMessage(submittedValue); - }, - [addMessage], - ); - - const handleIdePromptComplete = useCallback( - (result: IdeIntegrationNudgeResult) => { - if (result.userSelection === 'yes') { - if (result.isExtensionPreInstalled) { - handleSlashCommand('/ide enable'); - } else { - handleSlashCommand('/ide install'); - } - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); - } else if (result.userSelection === 'dismiss') { - settings.setValue( - SettingScope.User, - 'hasSeenIdeIntegrationNudge', - true, - ); - } - setIdePromptAnswered(true); - }, - [handleSlashCommand, settings], - ); - - const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); - - const { elapsedTime, currentLoadingPhrase } = - useLoadingIndicator(streamingState); - const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem }); - - const handleExit = useCallback( - ( - pressedOnce: boolean, - setPressedOnce: (value: boolean) => void, - timerRef: ReturnType>, - ) => { - // Fast double-press: Direct quit (preserve user habit) - if (pressedOnce) { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - // Exit directly without showing confirmation dialog - handleSlashCommand('/quit'); - return; - } - - // First press: Prioritize cleanup tasks - - // Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately" - if (quitConfirmationRequest) { - handleSlashCommand('/quit'); - return; - } - - /** - * For AuthDialog it is required to complete the authentication process, - * otherwise user cannot proceed to the next step. - * So a quit on AuthDialog should go with normal two press quit - * and without quit-confirm dialog. - */ - if (isAuthDialogOpen) { - setPressedOnce(true); - timerRef.current = setTimeout(() => { - setPressedOnce(false); - }, 500); - return; - } - - //1. Close other dialogs (highest priority) - if (closeAnyOpenDialog()) { - return; // Dialog closed, end processing - } - - // 2. Cancel ongoing requests - if (streamingState === StreamingState.Responding) { - cancelOngoingRequest?.(); - return; // Request cancelled, end processing - } - - // 3. Clear input buffer (if has content) - if (buffer.text.length > 0) { - buffer.setText(''); - return; // Input cleared, end processing - } - - // All cleanup tasks completed, show quit confirmation dialog - handleSlashCommand('/quit-confirm'); - }, - [ - isAuthDialogOpen, - handleSlashCommand, - quitConfirmationRequest, - closeAnyOpenDialog, - streamingState, - cancelOngoingRequest, - buffer, - ], - ); - - const handleGlobalKeypress = useCallback( - (key: Key) => { - // Debug log keystrokes if enabled - if (settings.merged.general?.debugKeystrokeLogging) { - console.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - - let enteringConstrainHeightMode = false; - if (!constrainHeight) { - enteringConstrainHeightMode = true; - setConstrainHeight(true); - } - - if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { - setShowErrorDetails((prev) => !prev); - } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { - const newValue = !showToolDescriptions; - setShowToolDescriptions(newValue); - - const mcpServers = config.getMcpServers(); - if (Object.keys(mcpServers || {}).length > 0) { - handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); - } - } else if ( - keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && - config.getIdeMode() && - ideContextState - ) { - // Show IDE status when in IDE mode and context is available. - handleSlashCommand('/ide status'); - } else if (keyMatchers[Command.QUIT](key)) { - // When authenticating, let AuthInProgress component handle Ctrl+C. - if (isAuthenticating) { - return; - } - handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); - } else if (keyMatchers[Command.EXIT](key)) { - if (buffer.text.length > 0) { - return; - } - handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); - } else if ( - keyMatchers[Command.SHOW_MORE_LINES](key) && - !enteringConstrainHeightMode - ) { - setConstrainHeight(false); - } - }, - [ - constrainHeight, - setConstrainHeight, - setShowErrorDetails, - showToolDescriptions, - setShowToolDescriptions, - config, - ideContextState, - handleExit, - ctrlCPressedOnce, - setCtrlCPressedOnce, - ctrlCTimerRef, - buffer.text.length, - ctrlDPressedOnce, - setCtrlDPressedOnce, - ctrlDTimerRef, - handleSlashCommand, - isAuthenticating, - settings.merged.general?.debugKeystrokeLogging, - ], - ); - - useKeypress(handleGlobalKeypress, { - isActive: true, - }); - - useEffect(() => { - if (config) { - setGeminiMdFileCount(config.getGeminiMdFileCount()); - } - }, [config, config.getGeminiMdFileCount]); - - const logger = useLogger(config.storage); - - useEffect(() => { - const fetchUserMessages = async () => { - const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; // Newest first - - const currentSessionUserMessages = history - .filter( - (item): item is HistoryItem & { type: 'user'; text: string } => - item.type === 'user' && - typeof item.text === 'string' && - item.text.trim() !== '', - ) - .map((item) => item.text) - .reverse(); // Newest first, to match pastMessagesRaw sorting - - // Combine, with current session messages being more recent - const combinedMessages = [ - ...currentSessionUserMessages, - ...pastMessagesRaw, - ]; - - // Deduplicate consecutive identical messages from the combined list (still newest first) - const deduplicatedMessages: string[] = []; - if (combinedMessages.length > 0) { - deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally - for (let i = 1; i < combinedMessages.length; i++) { - if (combinedMessages[i] !== combinedMessages[i - 1]) { - deduplicatedMessages.push(combinedMessages[i]); - } - } - } - // Reverse to oldest first for useInputHistory - setUserMessages(deduplicatedMessages.reverse()); - }; - fetchUserMessages(); - }, [history, logger]); - - const isInputActive = - (streamingState === StreamingState.Idle || - streamingState === StreamingState.Responding) && - !initError && - !isProcessing && - !showWelcomeBackDialog; - - const handleClearScreen = useCallback(() => { - clearItems(); - clearConsoleMessagesState(); - console.clear(); - refreshStatic(); - }, [clearItems, clearConsoleMessagesState, refreshStatic]); - - const mainControlsRef = useRef(null); - const pendingHistoryItemRef = useRef(null); - - useEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - setFooterHeight(fullFooterMeasurement.height); - } - }, [terminalHeight, consoleMessages, showErrorDetails]); - - const staticExtraHeight = /* margins and padding */ 3; - const availableTerminalHeight = useMemo( - () => terminalHeight - footerHeight - staticExtraHeight, - [terminalHeight, footerHeight], - ); - - useEffect(() => { - // skip refreshing Static during first mount - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - // debounce so it doesn't fire up too often during resize - const handler = setTimeout(() => { - setStaticNeedsRefresh(false); - refreshStatic(); - }, 300); - - return () => { - clearTimeout(handler); - }; - }, [terminalWidth, terminalHeight, refreshStatic]); - - useEffect(() => { - if (streamingState === StreamingState.Idle && staticNeedsRefresh) { - setStaticNeedsRefresh(false); - refreshStatic(); - } - }, [streamingState, refreshStatic, staticNeedsRefresh]); - - const filteredConsoleMessages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - - const branchName = useGitBranchName(config.getTargetDir()); - - const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; - if (fromSettings) { - return Array.isArray(fromSettings) ? fromSettings : [fromSettings]; - } - return getAllGeminiMdFilenames(); - }, [settings.merged.context?.fileName]); - - const initialPrompt = useMemo(() => config.getQuestion(), [config]); - const geminiClient = config.getGeminiClient(); - - useEffect(() => { - if ( - initialPrompt && - !initialPromptSubmitted.current && - !isAuthenticating && - !isAuthDialogOpen && - !isThemeDialogOpen && - !isEditorDialogOpen && - !isModelSelectionDialogOpen && - !isVisionSwitchDialogOpen && - !isSubagentCreateDialogOpen && - !showPrivacyNotice && - !showWelcomeBackDialog && - welcomeBackChoice !== 'restart' && - geminiClient?.isInitialized?.() - ) { - submitQuery(initialPrompt); - initialPromptSubmitted.current = true; - } - }, [ - initialPrompt, - submitQuery, - isAuthenticating, - isAuthDialogOpen, - isThemeDialogOpen, - isEditorDialogOpen, - isSubagentCreateDialogOpen, - showPrivacyNotice, - showWelcomeBackDialog, - welcomeBackChoice, - geminiClient, - isModelSelectionDialogOpen, - isVisionSwitchDialogOpen, - ]); - - if (quittingMessages) { - return ( - - {quittingMessages.map((item) => ( - - ))} - - ); +import { QuittingDisplay } from './components/QuittingDisplay.js'; +import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js'; +import { DefaultAppLayout } from './layouts/DefaultAppLayout.js'; + +const getContainerWidth = (terminalWidth: number): string => { + if (terminalWidth <= 80) { + return '98%'; + } + if (terminalWidth >= 132) { + return '90%'; } - const mainAreaWidth = Math.floor(terminalWidth * 0.9); - const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5)); - // Arbitrary threshold to ensure that items in the static area are large - // enough but not too large to make the terminal hard to use. - const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); - const placeholder = vimModeEnabled - ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." - : ' Type your message or @path/to/file'; + // Linearly interpolate between 80 columns (98%) and 132 columns (90%). + const t = (terminalWidth - 80) / (132 - 80); + const percentage = lerp(98, 90, t); + + return `${Math.round(percentage)}%`; +}; + +export const App = () => { + const uiState = useUIState(); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + const { columns } = useTerminalSize(); + const containerWidth = getContainerWidth(columns); + + if (uiState.quittingMessages) { + return ; + } return ( - - - {/* - * The Static component is an Ink intrinsic in which there can only be 1 per application. - * Because of this restriction we're hacking it slightly by having a 'header' item here to - * ensure that it's statically rendered. - * - * Background on the Static Item: Anything in the Static component is written a single time - * to the console. Think of it like doing a console.log and then never using ANSI codes to - * clear that content ever again. Effectively it has a moving frame that every time new static - * content is set it'll flush content to the terminal and move the area which it's "clearing" - * down a notch. Without Static the area which gets erased and redrawn continuously grows. - */} - - {!( - settings.merged.ui?.hideBanner || config.getScreenReader() - ) &&
} - {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( - - )} - , - ...history.map((h) => ( - - )), - ]} - > - {(item) => item} - - - - {pendingHistoryItems.map((item) => ( - - ))} - - - - - - {/* Move UpdateNotification to render update notification above input area */} - {updateInfo && } - {startupWarnings.length > 0 && ( - - {startupWarnings.map((warning, index) => ( - - {warning} - - ))} - - )} - {showWelcomeBackDialog && welcomeBackInfo?.hasHistory && ( - - )} - {showWorkspaceMigrationDialog ? ( - - ) : shouldShowIdePrompt && currentIDE ? ( - - ) : isFolderTrustDialogOpen ? ( - - ) : quitConfirmationRequest ? ( - { - const result = handleQuitConfirmationSelect(choice); - if (result?.shouldQuit) { - quitConfirmationRequest.onConfirm(true, result.action); - } else { - quitConfirmationRequest.onConfirm(false); - } - }} - /> - ) : shellConfirmationRequest ? ( - - ) : confirmationRequest ? ( - - {confirmationRequest.prompt} - - { - confirmationRequest.onConfirm(value); - }} - /> - - - ) : isThemeDialogOpen ? ( - - {themeError && ( - - {themeError} - - )} - - - ) : isSettingsDialogOpen ? ( - - closeSettingsDialog()} - onRestartRequest={() => process.exit(0)} - /> - - ) : isSubagentCreateDialogOpen ? ( - - - - ) : isAgentsManagerDialogOpen ? ( - - - - ) : isAuthenticating ? ( - <> - {isQwenAuth && isQwenAuthenticating ? ( - { - setAuthError( - 'Qwen OAuth authentication timed out. Please try again.', - ); - cancelQwenAuth(); - cancelAuthentication(); - openAuthDialog(); - }} - onCancel={() => { - setAuthError('Qwen OAuth authentication cancelled.'); - cancelQwenAuth(); - cancelAuthentication(); - openAuthDialog(); - }} - /> - ) : ( - { - setAuthError('Authentication timed out. Please try again.'); - cancelAuthentication(); - openAuthDialog(); - }} - /> - )} - {showErrorDetails && ( - - - - - - - )} - - ) : isAuthDialogOpen ? ( - - - - ) : isEditorDialogOpen ? ( - - {editorError && ( - - {editorError} - - )} - - - ) : isModelSelectionDialogOpen ? ( - - ) : isVisionSwitchDialogOpen ? ( - - ) : showPrivacyNotice ? ( - setShowPrivacyNotice(false)} - config={config} - /> - ) : ( - <> - - - {/* Display queued messages below loading indicator */} - {messageQueue.length > 0 && ( - - {messageQueue - .slice(0, MAX_DISPLAYED_QUEUED_MESSAGES) - .map((message, index) => { - // Ensure multi-line messages are collapsed for the preview. - // Replace all whitespace (including newlines) with a single space. - const preview = message.replace(/\s+/g, ' '); - - return ( - // Ensure the Box takes full width so truncation calculates correctly - - {/* Use wrap="truncate" to ensure it fits the terminal width and doesn't wrap */} - - {preview} - - - ); - })} - {messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && ( - - - ... (+ - {messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES} - more) - - - )} - - )} - - - - {process.env['GEMINI_SYSTEM_MD'] && ( - |⌐■_■| - )} - {ctrlCPressedOnce ? ( - - Press Ctrl+C again to confirm exit. - - ) : ctrlDPressedOnce ? ( - - Press Ctrl+D again to exit. - - ) : showEscapePrompt ? ( - Press Esc again to clear. - ) : ( - - )} - - - {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && - !shellModeActive && ( - - )} - {shellModeActive && } - - - - {showErrorDetails && ( - - - - - - - )} - - {isInputActive && ( - - )} - - )} - - {initError && streamingState !== StreamingState.Responding && ( - - {history.find( - (item) => - item.type === 'error' && item.text?.includes(initError), - )?.text ? ( - - { - history.find( - (item) => - item.type === 'error' && item.text?.includes(initError), - )?.text - } - - ) : ( - <> - - Initialization Error: {initError} - - - {' '} - Please check API key and configuration. - - - )} - - )} - {!settings.merged.ui?.hideFooter && ( -