Compare commits

...

115 Commits

Author SHA1 Message Date
gemini-cli-robot 888b7a2cd9 chore(release): v0.24.3 2026-01-16 22:43:48 +00:00
gemini-cli-robot b56a111594 chore(release): v0.24.0 2026-01-14 13:22:02 +00:00
gemini-cli-robot 48ee9bb308 chore(release): v0.24.0-preview.3 2026-01-14 06:24:32 +00:00
gemini-cli-robot 4b2701195a fix(patch): cherry-pick eda47f5 to release/v0.24.0-preview.2-pr-16557 [CONFLICTS] (#16577)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-01-14 14:04:14 +08:00
gemini-cli-robot df72e3af3d chore(release): v0.24.0-preview.2 2026-01-13 23:34:59 +00:00
gemini-cli-robot 334b813d81 fix(patch): cherry-pick 356f76e to release/v0.24.0-preview.1-pr-16252 to patch version v0.24.0-preview.1 and create version 0.24.0-preview.2 (#16552)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-13 22:40:08 +00:00
gemini-cli-robot 314f67a326 chore(release): v0.24.0-preview.1 2026-01-13 05:44:29 +00:00
gemini-cli-robot 0f0d1d8fc0 fix(patch): cherry-pick b54e688 to release/v0.24.0-preview.0-pr-16284 to patch version v0.24.0-preview.0 and create version 0.24.0-preview.1 (#16466)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-01-13 11:43:02 +08:00
gemini-cli-robot cb15a238fe chore(release): v0.24.0-preview.0 2026-01-07 01:43:58 +00:00
N. Taylor Mullen 59a18e710d Agent Skills: Initial Documentation & Tutorial (#15869) 2026-01-07 00:59:56 +00:00
Krushna Korade 5f027cb63a fix: hide broken skills object from settings dialog (#15766) 2026-01-07 00:55:13 +00:00
Christian Gunderman 4086abf375 Fix test. (#16011) 2026-01-07 00:25:16 +00:00
Jerop Kipruto 8f5bf33eac ci(github-actions): triage all new issues automatically (#16018) 2026-01-07 00:15:00 +00:00
Jerop Kipruto 7eeb7bd74c fix: limit scheduled issue triage queries to prevent argument list too long error (#16021) 2026-01-07 00:03:23 +00:00
Adam Weidman 96b9be3ec4 feat(agents): add support for remote agents (#16013) 2026-01-06 23:45:05 +00:00
joshualitt 1e31427da8 Remove trailing whitespace in yaml. (#16036) 2026-01-06 15:41:58 -08:00
Bryan Morgan 7feb2f8f42 Add 'reopened' type to issue labeling workflow 2026-01-06 18:17:12 -05:00
Bryan Morgan 2122604b32 Refactor parent issue check to use URLs 2026-01-06 18:00:39 -05:00
Bryan Morgan d4b4aede2f Add debugging logs for issue parent checks
Added debugging logs to inspect issue object and parent information.
2026-01-06 17:56:39 -05:00
Bryan Morgan 4b5c044272 Fix label-backlog-child-issues workflow logic 2026-01-06 17:48:08 -05:00
Shreya Keshive 2fe45834dd feat(admin): Introduce remote admin settings & implement secureModeEnabled/mcpEnabled (#15935) 2026-01-06 21:38:07 +00:00
joshualitt 56092bd782 feat(hooks): Add a hooks.enabled setting. (#15933) 2026-01-06 21:33:37 +00:00
Jack Wotherspoon c31f05356a fix: image token estimation (#16004) 2026-01-06 21:19:29 +00:00
Abhi 61dbab03e0 feat(ui): add visual indicators for hook execution (#15408) 2026-01-06 20:52:12 +00:00
Bryan Morgan 86b5995f12 Add workflow to label child issues for rollup (#16002) 2026-01-06 16:00:47 -05:00
Tommaso Sciortino d2849fda8a properly disable keyboard modes on exit (#16006) 2026-01-06 20:11:43 +00:00
Tommaso Sciortino a61fb058b7 fix: writeTodo construction (#16014) 2026-01-06 20:08:25 +00:00
christine betts 7edd803034 Fix settings command fallback (#15926) 2026-01-06 19:50:27 +00:00
N. Taylor Mullen 0c54136244 Agent Skills: Add skill directory to WorkspaceContext upon activation (#15870) 2026-01-06 19:24:37 +00:00
Ishaan Gupta cce4574143 Use GetOperation to poll for OnboardUser completion (#15827)
Co-authored-by: Vedant Mahajan <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-06 19:08:59 +00:00
Sehoon Shon 9172e28315 Add description for each settings item in /settings (#15936) 2026-01-06 19:06:15 +00:00
Gal Zahavi 6f4b2ad0b9 fix: default folder trust to untrusted for enhanced security (#15943) 2026-01-06 18:09:09 +00:00
N. Taylor Mullen 9a3ff6510f feat(policy): allow 'modes' in user and admin policies (#15977) 2026-01-06 17:56:50 +00:00
christine betts 4c67eef0f2 Inform user of missing settings on extensions update (#15944)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-06 17:26:01 +00:00
ppergame 7d4f97de7a fix(core): use correct interactive check for system prompt (#15020)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Billy Biggs <bbiggs@google.com>
2026-01-06 16:21:36 +00:00
Sehoon Shon cbd2eee9c4 remove manual string when displaying manual model in the footer (#15967) 2026-01-06 04:07:44 +00:00
Tommaso Sciortino 384fb6a465 Add setting to support OSC 52 paste (#15336) 2026-01-06 00:11:50 +00:00
N. Taylor Mullen 2cb33b2f76 Agent Skills: Implement /skills reload (#15865) 2026-01-05 23:12:51 +00:00
Tommaso Sciortino 8f0324d868 fix(cli): resolve paste issue on Windows terminals. (#15932) 2026-01-05 22:46:23 +00:00
Christian Gunderman fd7b6bf40a Fix failing unit tests (#15940) 2026-01-05 21:41:35 +00:00
Tommaso Sciortino ed8bad8c26 Fix order of preflight (#15941) 2026-01-05 21:32:02 +00:00
Christian Gunderman 6d1e27633a Support context injection via SessionStart hook. (#15746) 2026-01-05 21:27:53 +00:00
Manoj Naik 2da911e4a0 fix: prevent /copy crash on Windows by skipping /dev/tty (#15657)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-05 20:33:03 +00:00
Sehoon Shon e5d183031a Opt-in to persist model from /model (#15820) 2026-01-05 20:31:13 +00:00
Abhi b13c6b57ae chore: rename smart-edit to edit (#15923) 2026-01-05 20:25:54 +00:00
Dev Randalpura 3c92666ec2 Make default settings apply (#15354) 2026-01-05 20:00:20 +00:00
Vedant Mahajan dc6dda5c37 fix: avoid SDK warning by not accessing .text getter in logging (#15706)
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
2026-01-05 19:03:00 +00:00
Christian Gunderman 3997c7ff80 Fix terminal hang when user exits browser without logging in (#15748) 2026-01-05 19:02:55 +00:00
Abhi b4b49e7029 refactor(core): Extract and integrate ToolExecutor (#15900) 2026-01-05 05:48:41 +00:00
N. Taylor Mullen 615b218ff7 fix(cli): mock fs.readdir in consent tests for Windows compatibility (#15904) 2026-01-04 21:19:33 -08:00
Abhi f3625aab13 refactor: consolidate EditTool and SmartEditTool (#15857) 2026-01-05 04:52:14 +00:00
N. Taylor Mullen d3563e2f0e Agent Skills: Add gemini skills CLI management command (#15837) 2026-01-05 04:40:21 +00:00
Sandy Tao dd84c2fb83 feat(hooks): implement granular stop and block behavior for agent hooks (#15824) 2026-01-05 02:58:34 +00:00
N. Taylor Mullen bdb349e7f6 Agent Skills: Extension Support & Security Disclosure (#15834) 2026-01-04 22:45:07 +00:00
Abhi 12c7c9cc42 feat(core,cli): enforce mandatory MessageBus injection (Phase 3 Hard Migration) (#15776) 2026-01-04 22:11:43 +00:00
Abhi 90be9c3587 feat(core): Standardize Tool and Agent Invocation constructors (Phase 2) (#15775) 2026-01-04 20:51:23 +00:00
Abhi eec5d5ebf8 feat(core): restore MessageBus optionality for soft migration (Phase 1) (#15774) 2026-01-04 19:59:35 +00:00
Abhi d3c206c677 Unify shell security policy and remove legacy logic (#15770) 2026-01-04 05:19:00 +00:00
N. Taylor Mullen f0a039f7c0 Agent Skills: Unify Representation & Centralize Loading (#15833) 2026-01-03 16:24:36 -08:00
Gal Zahavi 30f5c4af4a fix(core): mock powershell output in shell-utils test (#15831) 2026-01-02 23:20:26 +00:00
N. Taylor Mullen e78c3fe4f0 Agent Skills: Status Bar Integration for Skill Counts (#15741) 2026-01-02 23:01:31 +00:00
Abhi c0ccb22460 chore: cleanup old smart edit settings (#15832) 2026-01-02 22:28:28 +00:00
N. Taylor Mullen 764b195977 Agent Skills: Implement Agent Integration and System Prompt Awareness (#15728) 2026-01-02 21:47:46 +00:00
Jack Wotherspoon 0f3555a4d2 feat: add folder suggestions to /dir add command (#15724) 2026-01-02 21:08:11 +00:00
Abhi 006de1dd31 Add security docs (#15739) 2026-01-02 20:22:30 +00:00
Gal Zahavi 18fef0db31 fix(core): improve shell command with redirection detection (#15683) 2026-01-02 19:36:59 +00:00
N. Taylor Mullen 958284dc24 Agent Skills: Implement Autonomous Activation Tool & Context Injection (#15725) 2026-01-02 19:15:06 +00:00
Sehoon Shon 788bb04f5c log fallback mode (#15817) 2026-01-02 18:19:43 +00:00
HyeongHo Jun 8a0190ca3b fix(core): handle unhandled promise rejection in mcp-client-manager (#14701) 2026-01-02 16:06:56 +00:00
Christian Gunderman c29a8c12b3 Fix build issues caused by year-specific linter rule (#15780) 2026-01-01 19:01:03 +00:00
Sandy Tao 05049b5abf feat(hooks): implement STOP_EXECUTION and enhance hook decision handling (#15685) 2025-12-30 23:22:53 +00:00
Sehoon Shon 3ebe4e6a8f feat(agents): Add remote agents to agent registry (#15711) 2025-12-30 23:11:51 +00:00
Abhi 0eb84f5133 chore: remove cot style comments (#15735) 2025-12-30 21:43:33 +00:00
N. Taylor Mullen de1233b8ca Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery (#15698) 2025-12-30 13:35:52 -08:00
christine betts ec11b8afbf Add extension settings info to /extensions list (#14905) 2025-12-30 21:09:48 +00:00
christine betts ec79fe1ab2 Add instructions to the extensions update info notification (#14907) 2025-12-30 19:50:33 +00:00
Christian Gunderman 90eb1e0281 Implement support for tool input modification (#15492) 2025-12-30 19:37:43 +00:00
Abhi 15c9f88da6 fix(hooks): deduplicate agent hooks and add cross-platform integration tests (#15701) 2025-12-30 19:13:16 +00:00
Abhi 4e6fee7fcd Revert "Update wittyPhrases.ts (#15697)" (#15719) 2025-12-30 16:50:28 +00:00
Shreya Keshive 1e08b150f7 refactor(auth): Refactor non-interactive mode auth validation & refresh (#15679) 2025-12-30 16:09:00 +00:00
segyges fb22f5b8ee Update wittyPhrases.ts (#15697)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-12-30 07:18:44 +00:00
Adam Weidman 848e8485cd feat(agents): add support for remote agents and multi-agent TOML files (#15437) 2025-12-30 00:12:16 +00:00
Sehoon Shon 07e597de40 Exponential back-off retries for retryable error without a specified … (#15684) 2025-12-29 22:23:23 +00:00
Adib234 10ae84869a Migrate console to coreEvents.emitFeedback or debugLogger (#15219) 2025-12-29 20:46:10 +00:00
Abhi dcd2449b1a refactor: deprecate legacy confirmation settings and enforce Policy Engine (#15626) 2025-12-29 19:22:42 +00:00
gemini-cli-robot a3d214f8d7 chore/release: bump version to 0.24.0-nightly.20251227.37be16243 (#15612) 2025-12-27 05:29:40 +00:00
Abhi 37be162435 fix(core): enable granular shell command allowlisting in policy engine (#15601) 2025-12-26 23:48:44 +00:00
Abhi 5566292cc8 refactor(core): extract static concerns from CoreToolScheduler (#15589) 2025-12-26 20:51:39 +00:00
Gal Zahavi 65e2144b3d Manual nightly version bump to 0.24.0-nightly.20251226.546baf993 (#15594) 2025-12-26 19:40:46 +00:00
Maple! a26d195404 fix(cli): add enableShellOutputEfficiency to settings schema (#15560) 2025-12-26 19:07:33 +00:00
Maple! 21388a0a40 fix(core): handle checkIsRepo failure in GitService.initialize (#15574) 2025-12-26 18:37:57 +00:00
Adib234 acecd80afa Resolve unhandled promise rejection in ide-client.ts (#15587) 2025-12-26 16:37:13 +00:00
Sehoon Shon 69fc75c0b2 do not persist the fallback model (#15483) 2025-12-26 16:08:44 +00:00
Sehoon Shon 9cdb267ba5 feat: Show snowfall animation for holiday theme (#15494)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-26 16:07:39 +00:00
Maple! 56b050422d chore(core): fix comment typo (#15558) 2025-12-26 04:31:43 +00:00
Vedant Mahajan 546baf9934 Added modifyOtherKeys protocol support for tmux (#15524)
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
2025-12-25 19:09:38 +00:00
Sehoon Shon e6344a8c24 Security: Project-level hook warnings (#15470) 2025-12-23 21:10:46 +00:00
Pyush Sinha 873d10df42 feat: terse transformations of image paths in text buffer (#4924)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: owenofbrien <86964623+owenofbrien@users.noreply.github.com>
2025-12-23 20:46:09 +00:00
Adam Weidman 02a36afc38 feat: Add A2A Client Manager and tests (#15485) 2025-12-23 20:27:16 +00:00
christine betts 563d81e08e Add experimental in-CLI extension install and uninstall subcommands (#15178)
Co-authored-by: Christine Betts <chrstn@google.com>
2025-12-23 19:48:27 +00:00
Jack Wotherspoon 5f28614760 chore: limit MCP resources display to 10 by default (#15489) 2025-12-23 19:39:35 +00:00
Jacob Richman b6b0727e28 Make schema validation errors non-fatal (#15487) 2025-12-23 18:26:37 +00:00
Jack Wotherspoon e9a601c1fe fix: add missing type field to MCPServerConfig (#15465) 2025-12-23 16:59:32 +00:00
Stoyan Dimitrov 308aa70718 refactor(core): remove deprecated permission aliases from BeforeToolHookOutput (#14855)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-23 16:13:20 +00:00
Niyas Hameed 6be034392f feat: automatic /model persistence across Gemini CLI sessions (#13199)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-23 14:23:43 +00:00
Christian Gunderman 3b1dbcd42d Implemented unified secrets sanitization and env. redaction options (#15348) 2025-12-23 03:18:27 +00:00
Jack Wotherspoon 2ac9fe08f7 chore: remove clipboard file (#15447) 2025-12-22 23:04:02 +00:00
Jack Wotherspoon 24c722454b chore: improve error messages for --resume (#15360) 2025-12-22 22:44:41 +00:00
ElecTwix 0a216b28f3 fix #15369, prevent crash on unhandled EIO error in readStdin cleanup (#15410)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2025-12-22 20:50:43 +00:00
Sandy Tao 9c48cd849b feat(ui): Add security warning and improve layout for Hooks list (#15440) 2025-12-22 20:47:56 +00:00
N. Taylor Mullen 0843d9af58 fix(core): use debugLogger.debug for startup profiler logs (#15443) 2025-12-22 20:45:33 +00:00
Abhi b0d5c4c058 feat(policy): implement dynamic mode-aware policy evaluation (#15307) 2025-12-22 20:25:07 +00:00
Christian Gunderman d18c96d6a1 Record timestamp with code assist metrics. (#15439) 2025-12-22 20:04:06 +00:00
Sehoon Shon dced409ac4 Add Folder Trust Support To Hooks (#15325) 2025-12-22 16:46:38 +00:00
Adam Weidman d6a2f1d670 chore(core): refactor model resolution and cleanup fallback logic (#15228) 2025-12-22 15:18:51 +00:00
401 changed files with 21952 additions and 8147 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- id: 'merge-queue-ci-skipper'
uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main
with:
secret: '${{ secrets.GITHUB_TOKEN }}'
secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
lint:
name: 'Lint'
@@ -39,7 +39,6 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
contains(github.event.issue.labels.*.name, 'status/need-triage') &&
(github.event_name != 'issue_comment' || (
contains(github.event.comment.body, '@gemini-cli /triage') &&
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')
@@ -75,11 +74,6 @@ jobs:
ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number }}'
LABELS: '${{ steps.get_issue_data.outputs.labels }}'
run: |
if ! echo "${LABELS}" | grep -q 'status/need-triage'; then
echo "Issue #${ISSUE_NUMBER_INPUT} does not have the 'status/need-triage' label. Stopping workflow."
exit 1
fi
if echo "${LABELS}" | grep -q 'area/'; then
echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow."
exit 1
@@ -45,11 +45,11 @@ jobs:
echo '🔍 Finding issues without labels...'
NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue no:label' --json number,title,body)"
--search 'is:open is:issue no:label' --limit 10 --json number,title,body)"
echo '🏷️ Finding issues that need triage...'
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)"
--search "is:open is:issue label:\"status/need-triage\"" --limit 10 --json number,title,body)"
echo '🔄 Merging and deduplicating issues...'
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
@@ -0,0 +1,40 @@
name: 'Label Child Issues for Project Rollup'
on:
issues:
types: ['opened', 'edited', 'reopened']
jobs:
labeler:
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
steps:
- name: 'Check for Parent Workstream and Apply Label'
uses: 'actions/github-script@v7'
with:
script: |
const issue = context.payload.issue;
const labelToAdd = 'workstream-rollup';
// --- Define the FULL URLs of the allowed parent workstreams ---
const allowedParentUrls = [
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15374',
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15456',
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15324'
];
// Check if the issue has a parent_issue_url and if it's in our allowed list.
if (issue && issue.parent_issue_url && allowedParentUrls.includes(issue.parent_issue_url)) {
console.log(`SUCCESS: Issue #${issue.number} is a child of a target workstream (${issue.parent_issue_url}). Adding label.`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [labelToAdd]
});
} else if (issue && issue.parent_issue_url) {
console.log(`FAILURE: Issue #${issue.number} has a parent, but it's not a target workstream. Parent URL: ${issue.parent_issue_url}`);
} else {
console.log(`FAILURE: Issue #${issue.number} is not a child of any issue. No action taken.`);
}
+9
View File
@@ -391,6 +391,15 @@ When working in the `/docs` directory, follow the guidelines in this section:
Only write high-value comments if at all. Avoid talking to the user through
comments.
## Logging and Error Handling
- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or
similar methods directly.
- **Non-User-Facing Logs:** For developer-facing debug messages, use
`debugLogger` (from `@google/gemini-cli-core`).
- **User-Facing Feedback:** To surface errors or warnings to the user, use
`coreEvents.emitFeedback` (from `@google/gemini-cli-core`).
## General requirements
- If there is something you do not understand or is ambiguous, seek confirmation
+8 -6
View File
@@ -94,15 +94,16 @@ they appear in the UI.
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` |
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` |
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `true` |
### Security
| UI Label | Setting | Description | Default |
| -------------------------- | ------------------------------ | -------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| UI Label | Setting | Description | Default |
| ----------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Allowed Environment Variables | `security.environmentVariableRedaction.allowed` | Environment variables to always allow (bypass redaction). | `[]` |
| Blocked Environment Variables | `security.environmentVariableRedaction.blocked` | Environment variables to always redact. | `[]` |
### Experimental
@@ -110,3 +111,4 @@ they appear in the UI.
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
+156
View File
@@ -0,0 +1,156 @@
# Agent Skills
_Note: This is an experimental feature enabled via `experimental.skills`. You
can also search for "Skills" within the `/settings` interactive UI to toggle
this and manage other skill-related settings._
Agent Skills allow you to extend Gemini CLI with specialized expertise,
procedural workflows, and task-specific resources. Based on the
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
self-contained directory that packages instructions and assets into a
discoverable capability.
## Overview
Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide
persistent project-wide background, Skills represent **on-demand expertise**.
This allows Gemini to maintain a vast library of specialized capabilities—such
as security auditing, cloud deployments, or codebase migrations—without
cluttering the model's immediate context window.
Gemini autonomously decides when to employ a skill based on your request and the
skill's description. When a relevant skill is identified, the model "pulls in"
the full instructions and resources required to complete the task using the
`activate_skill` tool.
## Key Benefits
- **Shared Expertise:** Package complex workflows (like a specific team's PR
review process) into a folder that anyone can use.
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
consistently by providing a procedural framework.
- **Resource Bundling:** Include scripts, templates, or example data alongside
instructions so the agent has everything it needs.
- **Progressive Disclosure:** Only skill metadata (name and description) is
loaded initially. Detailed instructions and resources are only disclosed when
the model explicitly activates the skill, saving context tokens.
## Skill Discovery Tiers
Gemini CLI discovers skills from three primary locations:
1. **Project Skills** (`.gemini/skills/`): Project-specific skills that are
typically committed to version control and shared with the team.
2. **User Skills** (`~/.gemini/skills/`): Personal skills available across all
your projects.
3. **Extension Skills**: Skills bundled within installed
[extensions](../extensions/index.md).
**Precedence:** If multiple skills share the same name, higher-precedence
locations override lower ones: **Project > User > Extension**.
## Managing Skills
### In an Interactive Session
Use the `/skills` slash command to view and manage available expertise:
- `/skills list` (default): Shows all discovered skills and their status.
- `/skills disable <name>`: Prevents a specific skill from being used.
- `/skills enable <name>`: Re-enables a disabled skill.
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use
`--scope project` to manage project-specific settings._
### From the Terminal
The `gemini skills` command provides management utilities:
```bash
# List all discovered skills
gemini skills list
# Enable/disable skills. Can use --scope to specify project or user
gemini skills enable my-expertise
gemini skills disable my-expertise
```
## Creating a Skill
A skill is a directory containing a `SKILL.md` file at its root. This file uses
YAML frontmatter for metadata and Markdown for instructions.
### Basic Structure
```markdown
---
name: <unique-name>
description: <what the skill does and when Gemini should use it>
---
<your instructions for how the agent should behave / use the skill>
```
- **`name`**: A unique identifier (lowercase, alphanumeric, and dashes).
- **`description`**: The most critical field. Gemini uses this to decide when
the skill is relevant. Be specific about the expertise provided.
- **Body**: Everything below the second `---` is injected as expert procedural
guidance for the model.
### Example: Team Code Reviewer
```markdown
---
name: code-reviewer
description:
Expertise in reviewing code for style, security, and performance. Use when the
user asks for "feedback," a "review," or to "check" their changes.
---
# Code Reviewer
You are an expert code reviewer. When reviewing code, follow this workflow:
1. **Analyze**: Review the staged changes or specific files provided. Ensure
that the changes are scoped properly and represent minimal changes required
to address the issue.
2. **Style**: Ensure code follows the project's conventions and idiomatic
patterns as described in the `GEMINI.md` file.
3. **Security**: Flag any potential security vulnerabilities.
4. **Tests**: Verify that new logic has corresponding test coverage and that
the test coverage adequately validates the changes.
Provide your feedback as a concise bulleted list of "Strengths" and
"Opportunities."
```
### Resource Conventions
While you can structure your skill directory however you like, the Agent Skills
standard encourages these conventions:
- **`scripts/`**: Executable scripts (bash, python, node) the agent can run.
- **`references/`**: Static documentation, schemas, or example data for the
agent to consult.
- **`assets/`**: Code templates, boilerplate, or binary resources.
When a skill is activated, Gemini CLI provides the model with a tree view of the
entire skill directory, allowing it to discover and utilize these assets.
## How it Works (Security & Privacy)
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
tiers and injects the name and description of all enabled skills into the
system prompt.
2. **Activation**: When Gemini identifies a task matching a skill's
description, it calls the `activate_skill` tool.
3. **Consent**: You will see a confirmation prompt in the UI detailing the
skill's name, purpose, and the directory path it will gain access to.
4. **Injection**: Upon your approval:
- The `SKILL.md` body and folder structure is added to the conversation
history.
- The skill's directory is added to the agent's allowed file paths, granting
it permission to read any bundled assets.
5. **Execution**: The model proceeds with the specialized expertise active. It
is instructed to prioritize the skill's procedural guidance within reason.
+3 -3
View File
@@ -297,7 +297,7 @@ Captures startup configuration and user prompt submissions.
#### Tools
Captures tool executions, output truncation, and Smart Edit behavior.
Captures tool executions, output truncation, and Edit behavior.
- `gemini_cli.tool_call`: Emitted for each tool (function) call.
- **Attributes**:
@@ -325,11 +325,11 @@ Captures tool executions, output truncation, and Smart Edit behavior.
- `lines` (int)
- `prompt_id` (string)
- `gemini_cli.smart_edit_strategy`: Smart Edit strategy chosen.
- `gemini_cli.edit_strategy`: Edit strategy chosen.
- **Attributes**:
- `strategy` (string)
- `gemini_cli.smart_edit_correction`: Smart Edit correction result.
- `gemini_cli.edit_correction`: Edit correction result.
- **Attributes**:
- `correction` ("success" | "failure")
+4
View File
@@ -2,6 +2,10 @@
This page contains tutorials for interacting with Gemini CLI.
## Agent Skills
- [Getting Started with Agent Skills](./tutorials/skills-getting-started.md)
## Setting up a Model Context Protocol (MCP) server
> [!CAUTION] Before using a third-party MCP server, ensure you trust its source
@@ -0,0 +1,124 @@
# Getting Started with Agent Skills
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
tutorial will guide you through creating your first skill, enabling it, and
using it in a session.
## 1. Enable Agent Skills
Agent Skills are currently an experimental feature and must be enabled in your
settings.
### Via the interactive UI
1. Start a Gemini CLI session by running `gemini`.
2. Type `/settings` to open the interactive settings dialog.
3. Search for "Skills".
4. Toggle **Agent Skills** to `true`.
5. Press `Esc` to save and exit. You may need to restart the CLI for the
changes to take effect.
### Via `settings.json`
Alternatively, you can manually edit your global settings file at
`~/.gemini/settings.json` (create it if it doesn't exist):
```json
{
"experimental": {
"skills": true
}
}
```
## 2. Create Your First Skill
A skill is a directory containing a `SKILL.md` file. Let's create an **API
Auditor** skill that helps you verify if local or remote endpoints are
responding correctly.
1. **Create the skill directory structure:**
```bash
mkdir -p .gemini/skills/api-auditor/scripts
```
2. **Create the `SKILL.md` file:** Create a file at
`.gemini/skills/api-auditor/SKILL.md` with the following content:
```markdown
---
name: api-auditor
description:
Expertise in auditing and testing API endpoints. Use when the user asks to
"check", "test", or "audit" a URL or API.
---
# API Auditor Instructions
You act as a QA engineer specialized in API reliability. When this skill is
active, you MUST:
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the
status of the provided URL.
2. **Report**: Analyze the output (status codes, latency) and explain any
failures in plain English.
3. **Secure**: Remind the user if they are testing a sensitive endpoint
without an `https://` protocol.
```
3. **Create the bundled Node.js script:** Create a file at
`.gemini/skills/api-auditor/scripts/audit.js`. This script will be used by
the agent to perform the actual check:
```javascript
// .gemini/skills/api-auditor/scripts/audit.js
const url = process.argv[2];
if (!url) {
console.error('Usage: node audit.js <url>');
process.exit(1);
}
console.log(`Auditing ${url}...`);
fetch(url, { method: 'HEAD' })
.then((r) => console.log(`Result: Success (Status ${r.status})`))
.catch((e) => console.error(`Result: Failed (${e.message})`));
```
## 3. Verify the Skill is Discovered
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
see if Gemini CLI has found your new skill.
In a Gemini CLI session:
```
/skills list
```
You should see `api-auditor` in the list of available skills.
## 4. Use the Skill in a Chat
Now, let's see the skill in action. Start a new session and ask a question about
an endpoint.
**User:** "Can you audit http://geminili.com"
Gemini will recognize the request matches the `api-auditor` description and will
ask for your permission to activate it.
**Model:** (After calling `activate_skill`) "I've activated the **api-auditor**
skill. I'll run the audit script now..."
Gemini will then use the `run_shell_command` tool to execute your bundled Node
script:
`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com`
## Next Steps
- Explore [Agent Skills Authoring Guide](../skills.md#creating-a-skill) to learn
about more advanced skill features.
- Learn how to share skills via [Extensions](../../extensions/index.md).
+111 -19
View File
@@ -621,6 +621,11 @@ their corresponding top-level category object in your `settings.json` file.
shell command. Defaults to 5 minutes.
- **Default:** `300`
- **`tools.shell.enableShellOutputEfficiency`** (boolean):
- **Description:** Enable shell output efficiency optimizations for better
performance.
- **Default:** `true`
- **`tools.autoAccept`** (boolean):
- **Description:** Automatically accept and execute tool calls that are
considered safe (e.g., read-only operations).
@@ -679,20 +684,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `1000`
- **Requires restart:** Yes
- **`tools.enableMessageBusIntegration`** (boolean):
- **Description:** Enable policy-based tool confirmation via message bus
integration. When enabled, tools automatically respect policy engine
decisions (ALLOW/DENY/ASK_USER) without requiring individual tool
implementations.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.enableHooks`** (boolean):
- **Description:** Enable the hooks system for intercepting and customizing
Gemini CLI behavior. When enabled, hooks configured in settings will execute
at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.).
Requires MessageBus integration.
- **Default:** `false`
- **Description:** Enables the hooks system experiment. When disabled, the
hooks system is completely deactivated regardless of other settings.
- **Default:** `true`
- **Requires restart:** Yes
#### `mcp`
@@ -712,12 +707,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
#### `useSmartEdit`
- **`useSmartEdit`** (boolean):
- **Description:** Enable the smart-edit tool instead of the replace tool.
- **Default:** `true`
#### `useWriteTodos`
- **`useWriteTodos`** (boolean):
@@ -746,6 +735,22 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.allowed`** (array):
- **Description:** Environment variables to always allow (bypass redaction).
- **Default:** `[]`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.blocked`** (array):
- **Description:** Environment variables to always redact.
- **Default:** `[]`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.enabled`** (boolean):
- **Description:** Enable redaction of environment variables that may contain
secrets.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.auth.selectedType`** (string):
- **Description:** The currently selected authentication type.
- **Default:** `undefined`
@@ -809,6 +814,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.skills`** (boolean):
- **Description:** Enable Agent Skills (experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
- **Description:** Enable the Codebase Investigator agent.
- **Default:** `true`
@@ -836,18 +846,39 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"auto"`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
(useful for remote sessions).
- **Default:** `false`
- **`experimental.introspectionAgentSettings.enabled`** (boolean):
- **Description:** Enable the Introspection Agent.
- **Default:** `false`
- **Requires restart:** Yes
#### `skills`
- **`skills.disabled`** (array):
- **Description:** List of disabled skills.
- **Default:** `[]`
- **Requires restart:** Yes
#### `hooks`
- **`hooks.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `false`
- **`hooks.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
Hooks in this list will not execute even if configured.
- **Default:** `[]`
- **`hooks.notifications`** (boolean):
- **Description:** Show visual indicators when hooks are executing.
- **Default:** `true`
- **`hooks.BeforeTool`** (array):
- **Description:** Hooks that execute before tool execution. Can intercept,
validate, or modify tool calls.
@@ -902,6 +933,21 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hooks that execute before tool selection. Can filter or
prioritize available tools dynamically.
- **Default:** `[]`
#### `admin`
- **`admin.secureModeEnabled`** (boolean):
- **Description:** If true, disallows yolo mode from being used.
- **Default:** `false`
- **`admin.extensions.enabled`** (boolean):
- **Description:** If false, disallows extensions from being installed or
used.
- **Default:** `true`
- **`admin.mcp.enabled`** (boolean):
- **Description:** If false, disallows MCP servers from being used.
- **Default:** `true`
<!-- SETTINGS-AUTOGEN:END -->
#### `mcpServers`
@@ -1171,6 +1217,52 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the endpoint for the code assist server.
- This is useful for development and testing.
### Environment variable redaction
To prevent accidental leakage of sensitive information, Gemini CLI automatically
redacts potential secrets from environment variables when executing tools (such
as shell commands). This "best effort" redaction applies to variables inherited
from the system or loaded from `.env` files.
**Default Redaction Rules:**
- **By Name:** Variables are redacted if their names contain sensitive terms
like `TOKEN`, `SECRET`, `PASSWORD`, `KEY`, `AUTH`, `CREDENTIAL`, `PRIVATE`, or
`CERT`.
- **By Value:** Variables are redacted if their values match known secret
patterns, such as:
- Private keys (RSA, OpenSSH, PGP, etc.)
- Certificates
- URLs containing credentials
- API keys and tokens (GitHub, Google, AWS, Stripe, Slack, etc.)
- **Specific Blocklist:** Certain variables like `CLIENT_ID`, `DB_URI`,
`DATABASE_URL`, and `CONNECTION_STRING` are always redacted by default.
**Allowlist (Never Redacted):**
- Common system variables (e.g., `PATH`, `HOME`, `USER`, `SHELL`, `TERM`,
`LANG`).
- Variables starting with `GEMINI_CLI_`.
- GitHub Action specific variables.
**Configuration:**
You can customize this behavior in your `settings.json` file:
- **`security.allowedEnvironmentVariables`**: A list of variable names to
_never_ redact, even if they match sensitive patterns.
- **`security.blockedEnvironmentVariables`**: A list of variable names to
_always_ redact, even if they don't match sensitive patterns.
```json
{
"security": {
"allowedEnvironmentVariables": ["MY_PUBLIC_KEY", "NOT_A_SECRET_TOKEN"],
"blockedEnvironmentVariables": ["INTERNAL_IP_ADDRESS"]
}
}
```
## Command-line arguments
Arguments passed directly when running the CLI can override other configurations
+177 -127
View File
@@ -4,128 +4,6 @@ This guide covers security considerations, performance optimization, debugging
techniques, and privacy considerations for developing and deploying hooks in
Gemini CLI.
## Security considerations
### Validate all inputs
Never trust data from hooks without validation. Hook inputs may contain
user-provided data that could be malicious:
```bash
#!/usr/bin/env bash
input=$(cat)
# Validate JSON structure
if ! echo "$input" | jq empty 2>/dev/null; then
echo "Invalid JSON input" >&2
exit 1
fi
# Validate required fields
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
if [ -z "$tool_name" ]; then
echo "Missing tool_name field" >&2
exit 1
fi
```
### Use timeouts
Set reasonable timeouts to prevent hooks from hanging indefinitely:
```json
{
"hooks": {
"BeforeTool": [
{
"matcher": "*",
"hooks": [
{
"name": "slow-validator",
"type": "command",
"command": "./hooks/validate.sh",
"timeout": 5000
}
]
}
]
}
}
```
**Recommended timeouts:**
- Fast validation: 1000-5000ms
- Network requests: 10000-30000ms
- Heavy computation: 30000-60000ms
### Limit permissions
Run hooks with minimal required permissions:
```bash
#!/usr/bin/env bash
# Don't run as root
if [ "$EUID" -eq 0 ]; then
echo "Hook should not run as root" >&2
exit 1
fi
# Check file permissions before writing
if [ -w "$file_path" ]; then
# Safe to write
else
echo "Insufficient permissions" >&2
exit 1
fi
```
### Scan for secrets
Use `BeforeTool` hooks to prevent committing sensitive data:
```javascript
const SECRET_PATTERNS = [
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
/secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/AKIA[0-9A-Z]{16}/, // AWS access key
/ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token
/sk-[a-zA-Z0-9]{48}/, // OpenAI API key
];
function containsSecret(content) {
return SECRET_PATTERNS.some((pattern) => pattern.test(content));
}
```
### Review external scripts
Always review hook scripts from untrusted sources before enabling them:
```bash
# Review before installing
cat third-party-hook.sh | less
# Check for suspicious patterns
grep -E 'curl|wget|ssh|eval' third-party-hook.sh
# Verify hook source
ls -la third-party-hook.sh
```
### Sandbox untrusted hooks
For maximum security, consider running untrusted hooks in isolated environments:
```bash
# Run hook in Docker container
docker run --rm \
-v "$GEMINI_PROJECT_DIR:/workspace:ro" \
-i untrusted-hook-image \
/hook-script.sh < input.json
```
## Performance
### Keep hooks fast
@@ -140,11 +18,13 @@ const data2 = await fetch(url2).then((r) => r.json());
const data3 = await fetch(url3).then((r) => r.json());
// Prefer parallel operations for better performance
const [data1, data2, data3] = await Promise.all([
fetch(url1).then((r) => r.json()),
fetch(url2).then((r) => r.json()),
fetch(url3).then((r) => r.json()),
]);
// Start requests concurrently
const p1 = fetch(url1).then((r) => r.json());
const p2 = fetch(url2).then((r) => r.json());
const p3 = fetch(url3).then((r) => r.json());
// Wait for all results
const [data1, data2, data3] = await Promise.all([p1, p2, p3]);
```
### Cache expensive operations
@@ -714,6 +594,176 @@ if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
fi
```
## Using Hooks Securely
### Threat Model
Understanding where hooks come from and what they can do is critical for secure
usage.
| Hook Source | Description |
| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| **System** | Configured by system administrators (e.g., `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. |
| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. |
| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). |
| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. |
#### Project Hook Security
When you open a project with hooks defined in `.gemini/settings.json`:
1. **Detection**: Gemini CLI detects the hooks.
2. **Identification**: A unique identity is generated for each hook based on its
`name` and `command`.
3. **Warning**: If this specific hook identity has not been seen before, a
**warning** is displayed.
4. **Execution**: The hook is executed (unless specific security settings block
it).
5. **Trust**: The hook is marked as "trusted" for this project.
> [!IMPORTANT] **Modification Detection**: If the `command` string of a project
> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will
> treat it as a **new, untrusted hook** and warn you again. This prevents
> malicious actors from silently swapping a verified command for a malicious
> one.
### Risks
| Risk | Description |
| :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **Arbitrary Code Execution** | Hooks run as your user. They can do anything you can do (delete files, install software). |
| **Data Exfiltration** | A hook could read your input (prompts), output (code), or environment variables (`GEMINI_API_KEY`) and send them to a remote server. |
| **Prompt Injection** | Malicious content in a file or web page could trick an LLM into running a tool that triggers a hook in an unexpected way. |
### Mitigation Strategies
#### Verify the source
**Verify the source** of any project hooks or extensions before enabling them.
- For open-source projects, a quick review of the hook scripts is recommended.
- For extensions, ensure you trust the author or publisher (e.g., verified
publishers, well-known community members).
- Be cautious with obfuscated scripts or compiled binaries from unknown sources.
#### Sanitize Environment
Hooks inherit the environment of the Gemini CLI process, which may include
sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you
should be cautious.
- **Avoid printing environment variables** to stdout/stderr unless necessary.
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
excluded from version control.
**System Administrators:** You can enforce environment variable redaction by
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
```json
{
"security": {
"environmentVariableRedaction": {
"enabled": true,
"blocked": ["MY_SECRET_KEY"],
"allowed": ["SAFE_VAR"]
}
}
}
```
## Authoring Secure Hooks
When writing your own hooks, follow these practices to ensure they are robust
and secure.
### Validate all inputs
Never trust data from hooks without validation. Hook inputs often come from the
LLM or user prompts, which can be manipulated.
```bash
#!/usr/bin/env bash
input=$(cat)
# Validate JSON structure
if ! echo "$input" | jq empty 2>/dev/null; then
echo "Invalid JSON input" >&2
exit 1
fi
# Validate tool_name explicitly
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
if [[ "$tool_name" != "write_file" && "$tool_name" != "read_file" ]]; then
echo "Unexpected tool: $tool_name" >&2
exit 1
fi
```
### Use timeouts
Prevent denial-of-service (hanging agents) by enforcing timeouts. Gemini CLI
defaults to 60 seconds, but you should set stricter limits for fast hooks.
```json
{
"hooks": {
"BeforeTool": [
{
"matcher": "*",
"hooks": [
{
"name": "fast-validator",
"command": "./hooks/validate.sh",
"timeout": 5000 // 5 seconds
}
]
}
]
}
}
```
### Limit permissions
Run hooks with minimal required permissions:
```bash
#!/usr/bin/env bash
# Don't run as root
if [ "$EUID" -eq 0 ]; then
echo "Hook should not run as root" >&2
exit 1
fi
# Check file permissions before writing
if [ -w "$file_path" ]; then
# Safe to write
else
echo "Insufficient permissions" >&2
exit 1
fi
```
### Example: Secret Scanner
Use `BeforeTool` hooks to prevent committing sensitive data. This is a powerful
pattern for enhancing security in your workflow.
```javascript
const SECRET_PATTERNS = [
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
/secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/AKIA[0-9A-Z]{16}/, // AWS access key
/ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token
/sk-[a-zA-Z0-9]{48}/, // OpenAI API key
];
function containsSecret(content) {
return SECRET_PATTERNS.some((pattern) => pattern.test(content));
}
```
## Privacy considerations
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
+22
View File
@@ -27,6 +27,28 @@ With hooks, you can:
Hooks run synchronously as part of the agent loop—when a hook event fires,
Gemini CLI waits for all matching hooks to complete before continuing.
## Security and Risks
> [!WARNING] **Hooks execute arbitrary code with your user privileges.**
By configuring hooks, you are explicitly allowing Gemini CLI to run shell
commands on your machine. Malicious or poorly configured hooks can:
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
remote servers.
- **Modify system**: Delete files, install malware, or change system settings.
- **Consume resources**: Run infinite loops or crash your system.
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
particularly risky when opening third-party projects or extensions from
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
project hook (identified by its name and command), but it is **your
responsibility** to review these hooks (and any installed extensions) before
trusting them.
See [Security Considerations](best-practices.md#using-hooks-securely) for a
detailed threat model and mitigation strategies.
## Core concepts
### Hook events
+4
View File
@@ -88,6 +88,10 @@
"label": "Session Management",
"slug": "docs/cli/session-management"
},
{
"label": "Agent Skills",
"slug": "docs/cli/skills"
},
{
"label": "Settings",
"slug": "docs/cli/settings"
+18 -10
View File
@@ -12,7 +12,7 @@ import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import vitest from '@vitest/eslint-plugin';
import globals from 'globals';
import licenseHeader from 'eslint-plugin-license-header';
import headers from 'eslint-plugin-headers';
import path from 'node:path';
import url from 'node:url';
@@ -164,6 +164,7 @@ export default tseslint.config(
'prefer-arrow-callback': 'error',
'prefer-const': ['error', { destructuring: 'all' }],
radix: 'error',
'no-console': 'error',
'default-case': 'error',
'@typescript-eslint/await-thenable': ['error'],
'@typescript-eslint/no-floating-promises': ['error'],
@@ -209,19 +210,26 @@ export default tseslint.config(
{
files: ['./**/*.{tsx,ts,js}'],
plugins: {
'license-header': licenseHeader,
headers,
import: importPlugin,
},
rules: {
'license-header/header': [
'headers/header-format': [
'error',
[
'/**',
' * @license',
' * Copyright 2025 Google LLC',
' * SPDX-License-Identifier: Apache-2.0',
' */',
],
{
source: 'string',
content: [
'@license',
'Copyright (year) Google LLC',
'SPDX-License-Identifier: Apache-2.0',
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
},
},
},
],
'import/enforce-node-protocol-usage': ['error', 'always'],
},
@@ -32,7 +32,7 @@ describe('Interactive Mode', () => {
await run.sendKeys(
'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.',
);
await run.sendKeys('\r');
await run.type('\r');
// Wait for the specific end marker.
await run.expectText('THE_END.', 30000);
+15 -6
View File
@@ -95,7 +95,10 @@ describe('extension reloading', () => {
// Poll for the updated list
await rig.pollCommand(
() => run.sendKeys('\u0015/mcp list\r'),
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
@@ -110,9 +113,9 @@ describe('extension reloading', () => {
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.sendKeys('\r');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.sendKeys('\r');
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
@@ -123,7 +126,10 @@ describe('extension reloading', () => {
// Poll for the updated extension version
await rig.pollCommand(
() => run.sendKeys('\u0015/extensions list\r'),
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
@@ -133,7 +139,10 @@ describe('extension reloading', () => {
// Poll for the updated mcp tool
await rig.pollCommand(
() => run.sendKeys('\u0015/mcp list\r'),
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
@@ -146,7 +155,7 @@ describe('extension reloading', () => {
);
await run.sendText('/quit');
await run.sendKeys('\r');
await run.type('\r');
// Clean things up.
await serverA.stop();
@@ -37,7 +37,7 @@ describe('Interactive file system', () => {
// Step 1: Read the file
const readPrompt = `Read the version from ${fileName}`;
await run.type(readPrompt);
await run.sendKeys('\r');
await run.type('\r');
const readCall = await rig.waitForToolCall('read_file', 30000);
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
@@ -45,7 +45,7 @@ describe('Interactive file system', () => {
// Step 2: Write the file
const writePrompt = `now change the version to 1.0.1 in the file`;
await run.type(writePrompt);
await run.sendKeys('\r');
await run.type('\r');
// Check tool calls made with right args
await rig.expectToolCallSuccess(
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"."}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Final Answer"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Responding**\n\nI will respond to the user's request.\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Response to: "}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":5,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Hello World"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":7,"totalTokenCount":127,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}}]}
+214
View File
@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
describe('Hooks Agent Flow', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});
describe('BeforeAgent Hooks', () => {
it('should inject additional context via BeforeAgent hook', async () => {
await rig.setup('should inject additional context via BeforeAgent hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-agent-flow.responses',
),
});
const hookScript = `
try {
const output = {
decision: "allow",
hookSpecificOutput: {
hookEventName: "BeforeAgent",
additionalContext: "SYSTEM INSTRUCTION: This is injected context."
}
};
process.stdout.write(JSON.stringify(output));
} catch (e) {
console.error('Failed to write stdout:', e);
process.exit(1);
}
console.error('DEBUG: BeforeAgent hook executed');
`;
const scriptPath = join(rig.testDir!, 'before_agent_context.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should inject additional context via BeforeAgent hook', {
settings: {
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
await rig.run({ args: 'Hello test' });
// Verify hook execution and telemetry
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
const hookLogs = rig.readHookLogs();
const beforeAgentLog = hookLogs.find(
(log) => log.hookCall.hook_event_name === 'BeforeAgent',
);
expect(beforeAgentLog).toBeDefined();
expect(beforeAgentLog?.hookCall.stdout).toContain('injected context');
expect(beforeAgentLog?.hookCall.stdout).toContain('"decision":"allow"');
expect(beforeAgentLog?.hookCall.stdout).toContain(
'SYSTEM INSTRUCTION: This is injected context.',
);
});
});
describe('AfterAgent Hooks', () => {
it('should receive prompt and response in AfterAgent hook', async () => {
await rig.setup('should receive prompt and response in AfterAgent hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-agent-flow.responses',
),
});
const hookScript = `
const fs = require('fs');
try {
const input = fs.readFileSync(0, 'utf-8');
console.error('DEBUG: AfterAgent hook input received');
process.stdout.write("Received Input: " + input);
} catch (err) {
console.error('Hook Failed:', err);
process.exit(1);
}
`;
const scriptPath = join(rig.testDir!, 'after_agent_verify.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should receive prompt and response in AfterAgent hook', {
settings: {
hooks: {
enabled: true,
AfterAgent: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
await rig.run({ args: 'Hello validation' });
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
const hookLogs = rig.readHookLogs();
const afterAgentLog = hookLogs.find(
(log) => log.hookCall.hook_event_name === 'AfterAgent',
);
expect(afterAgentLog).toBeDefined();
// Verify the hook stdout contains the input we echoed which proves the
// hook received the prompt and response
expect(afterAgentLog?.hookCall.stdout).toContain('Received Input');
expect(afterAgentLog?.hookCall.stdout).toContain('Hello validation');
// The fake response contains "Hello World"
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
});
});
describe('Multi-step Loops', () => {
it('should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', async () => {
await rig.setup(
'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-agent-flow-multistep.responses',
),
settings: {
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
{
type: 'command',
command: `node -e "console.log('BeforeAgent Fired')"`,
timeout: 5000,
},
],
},
],
AfterAgent: [
{
hooks: [
{
type: 'command',
command: `node -e "console.log('AfterAgent Fired')"`,
timeout: 5000,
},
],
},
],
},
},
},
);
await rig.run({ args: 'Do a multi-step task' });
const hookLogs = rig.readHookLogs();
const beforeAgentLogs = hookLogs.filter(
(log) => log.hookCall.hook_event_name === 'BeforeAgent',
);
const afterAgentLogs = hookLogs.filter(
(log) => log.hookCall.hook_event_name === 'AfterAgent',
);
expect(beforeAgentLogs).toHaveLength(1);
expect(afterAgentLogs).toHaveLength(1);
const afterAgentLog = afterAgentLogs[0];
expect(afterAgentLog).toBeDefined();
expect(afterAgentLog?.hookCall.stdout).toContain('AfterAgent Fired');
});
});
});
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initializing File Creation**\n\nI'm starting to think about how to make a new file called `test.txt`. My plan is to use a `write_file` tool. I'll need to specify the location and what the file should contain. For now, it will be empty.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"totalTokenCount":13269,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":""}},"thoughtSignature":"CiQBcsjafJ20Qbx0YvING6aZ0wYoGWJh3eqornOG4E4AfBLiVsQKXwFyyNp8UlwYs/pv9IRQQGhDlrmlOJF2hfQijryyUYLI+qjDYTpZ6KKIfZF4+vS0soL2BJ3eTXA6gaadFEfNQem3WQVeQoKLFoW4Hv4mbasXqQc0K3p15DuSAtZZENTbCnsBcsjafGK+BJyF/Npnd7gyU0TL5PXePT0nuDFjhJDxlSRUJHDP315TewD3PUYsXd10oWsfhy4B5AngyUiBPUoajdsxg8WxaxnOZYqcp8EIuwtGZrCTev6IihT5nE5jj7u0P9vtnCmkAc6p+4O7Q7Jku1uVGqeJChgzI4YKSAFyyNp8EXSdbttV4xzX+NLKkc276L8Y63tnKU6/Y7fc9/58tU29DSdrgwfe9qmvwtTsO0piFXSLazqHJt8h2bgR7A7GnKDiIA=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}}]}
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"original content","file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have created the file."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
+438 -163
View File
@@ -32,10 +32,8 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -44,7 +42,7 @@ describe('Hooks System Integration', () => {
{
type: 'command',
command:
'echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"File writing blocked by security policy\\"}"',
"node -e \"console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}))\"",
timeout: 5000,
},
],
@@ -86,10 +84,8 @@ describe('Hooks System Integration', () => {
'hooks-system.allow-tool.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
@@ -97,7 +93,7 @@ describe('Hooks System Integration', () => {
{
type: 'command',
command:
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"File writing approved\\"}"',
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'File writing approved'}))\"",
timeout: 5000,
},
],
@@ -129,17 +125,15 @@ describe('Hooks System Integration', () => {
describe('Command Hooks - Additional Context', () => {
it('should add additional context from AfterTool hooks', async () => {
const command =
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"AfterTool\\", \\"additionalContext\\": \\"Security scan: File content appears safe\\"}}"';
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
await rig.setup('should add additional context from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-tool-context.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
AfterTool: [
{
matcher: 'read_file',
@@ -190,40 +184,35 @@ describe('Hooks System Integration', () => {
'hooks-system.before-model.responses',
),
});
const hookScript = `#!/bin/bash
echo '{
"decision": "allow",
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_request": {
"messages": [
const hookScript = `const fs = require('fs');
console.log(JSON.stringify({
decision: "allow",
hookSpecificOutput: {
hookEventName: "BeforeModel",
llm_request: {
messages: [
{
"role": "user",
"content": "Please respond with exactly: The security hook modified this request successfully."
role: "user",
content: "Please respond with exactly: The security hook modified this request successfully."
}
]
}
}
}'`;
}));`;
const scriptPath = join(rig.testDir!, 'before_model_hook.sh');
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
// Make executable
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${scriptPath}"`);
await rig.setup('should modify LLM requests with BeforeModel hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
{
type: 'command',
command: scriptPath,
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
@@ -250,7 +239,9 @@ echo '{
expect(hookTelemetryFound[0].hookCall.hook_event_name).toBe(
'BeforeModel',
);
expect(hookTelemetryFound[0].hookCall.hook_name).toBe(scriptPath);
expect(hookTelemetryFound[0].hookCall.hook_name).toBe(
`node "${scriptPath}"`,
);
expect(hookTelemetryFound[0].hookCall.hook_input).toBeDefined();
expect(hookTelemetryFound[0].hookCall.hook_output).toBeDefined();
expect(hookTelemetryFound[0].hookCall.exit_code).toBe(0);
@@ -270,43 +261,39 @@ echo '{
),
});
// Create a hook script that modifies the LLM response
const hookScript = `#!/bin/bash
echo '{
"hookSpecificOutput": {
"hookEventName": "AfterModel",
"llm_response": {
"candidates": [
const hookScript = `const fs = require('fs');
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "AfterModel",
llm_response: {
candidates: [
{
"content": {
"role": "model",
"parts": [
content: {
role: "model",
parts: [
"[FILTERED] Response has been filtered for security compliance."
]
},
"finishReason": "STOP"
finishReason: "STOP"
}
]
}
}
}'`;
}));`;
const scriptPath = join(rig.testDir!, 'after_model_hook.sh');
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${scriptPath}"`);
await rig.setup('should modify LLM responses with AfterModel hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
AfterModel: [
{
hooks: [
{
type: 'command',
command: scriptPath,
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
@@ -343,17 +330,15 @@ echo '{
);
// Create inline hook command (works on both Unix and Windows)
const hookCommand =
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeToolSelection\\", \\"toolConfig\\": {\\"mode\\": \\"ANY\\", \\"allowedFunctionNames\\": [\\"read_file\\", \\"run_shell_command\\"]}}}"';
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
await rig.setup(
'should modify tool selection with BeforeToolSelection hooks',
{
settings: {
debugMode: true,
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeToolSelection: [
{
hooks: [
@@ -404,32 +389,28 @@ echo '{
),
});
// Create a hook script that adds context to the prompt
const hookScript = `#!/bin/bash
echo '{
"decision": "allow",
"hookSpecificOutput": {
"hookEventName": "BeforeAgent",
"additionalContext": "SYSTEM INSTRUCTION: You are in a secure environment. Always mention security compliance in your responses."
const hookScript = `const fs = require('fs');
console.log(JSON.stringify({
decision: "allow",
hookSpecificOutput: {
hookEventName: "BeforeAgent",
additionalContext: "SYSTEM INSTRUCTION: You are in a secure environment. Always mention security compliance in your responses."
}
}'`;
}));`;
const scriptPath = join(rig.testDir!, 'before_agent_hook.sh');
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
writeFileSync(scriptPath, hookScript);
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${scriptPath}"`);
await rig.setup('should augment prompts with BeforeAgent hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
{
type: 'command',
command: scriptPath,
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
@@ -452,9 +433,10 @@ echo '{
describe('Notification Hooks - Permission Handling', () => {
it('should handle notification hooks for tool permissions', async () => {
// Create inline hook command (works on both Unix and Windows)
// Create inline hook command (works on both Unix and Windows)
const hookCommand =
'echo "{\\"suppressOutput\\": false, \\"systemMessage\\": \\"Permission request logged by security hook\\"}"';
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
await rig.setup('should handle notification hooks for tool permissions', {
fakeResponsesPath: join(
@@ -464,11 +446,11 @@ echo '{
settings: {
// Configure tools to enable hooks and require confirmation to trigger notifications
tools: {
enableHooks: true,
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['run_shell_command'],
},
hooks: {
enabled: true,
Notification: [
{
matcher: 'ToolPermission',
@@ -548,9 +530,9 @@ echo '{
it('should execute hooks sequentially when configured', async () => {
// Create inline hook commands (works on both Unix and Windows)
const hook1Command =
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"Step 1: Initial validation passed.\\"}}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 1: Initial validation passed.'}}))\"";
const hook2Command =
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"Step 2: Security check completed.\\"}}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
await rig.setup('should execute hooks sequentially when configured', {
fakeResponsesPath: join(
@@ -558,10 +540,8 @@ echo '{
'hooks-system.sequential-execution.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
sequential: true,
@@ -621,36 +601,33 @@ echo '{
),
});
// Create a hook script that validates the input format
const hookScript = `#!/bin/bash
# Read JSON input from stdin
input=$(cat)
const hookScript = `const fs = require('fs');
const input = fs.readFileSync(0, 'utf-8');
try {
const json = JSON.parse(input);
// Check fields
if (json.session_id && json.cwd && json.hook_event_name && json.timestamp && json.tool_name && json.tool_input) {
console.log(JSON.stringify({decision: "allow", reason: "Input format is correct"}));
} else {
console.log(JSON.stringify({decision: "block", reason: "Input format is invalid"}));
}
} catch (e) {
console.log(JSON.stringify({decision: "block", reason: "Invalid JSON"}));
}`;
# Check for required fields
if echo "$input" | jq -e '.session_id and .cwd and .hook_event_name and .timestamp and .tool_name and .tool_input' > /dev/null 2>&1; then
echo '{"decision": "allow", "reason": "Input format is correct"}'
exit 0
else
echo '{"decision": "block", "reason": "Input format is invalid"}'
exit 0
fi`;
const scriptPath = join(rig.testDir!, 'input_validation_hook.sh');
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
writeFileSync(scriptPath, hookScript);
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${scriptPath}"`);
await rig.setup('should provide correct input format to hooks', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
{
type: 'command',
command: scriptPath,
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
@@ -682,11 +659,11 @@ fi`;
it('should handle hooks for all major event types', async () => {
// Create inline hook commands (works on both Unix and Windows)
const beforeToolCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"BeforeTool: File operation logged\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'BeforeTool: File operation logged'}))\"";
const afterToolCommand =
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"AfterTool\\", \\"additionalContext\\": \\"AfterTool: Operation completed successfully\\"}}"';
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'AfterTool: Operation completed successfully'}}))\"";
const beforeAgentCommand =
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"BeforeAgent: User request processed\\"}}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
await rig.setup('should handle hooks for all major event types', {
fakeResponsesPath: join(
@@ -694,10 +671,8 @@ fi`;
'hooks-system.multiple-events.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeAgent: [
{
hooks: [
@@ -802,17 +777,15 @@ fi`;
// Create a hook script that fails
// Create inline hook commands (works on both Unix and Windows)
// Failing hook: exits with non-zero code
const failingCommand = 'exit 1';
const failingCommand = 'node -e "process.exit(1)"';
// Working hook: returns success with JSON
const workingCommand =
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"Working hook succeeded\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
await rig.setup('should handle hook failures gracefully', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -855,7 +828,7 @@ fi`;
it('should generate telemetry events for hook executions', async () => {
// Create inline hook command (works on both Unix and Windows)
const hookCommand =
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"Telemetry test hook\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
await rig.setup('should generate telemetry events for hook executions', {
fakeResponsesPath: join(
@@ -863,10 +836,8 @@ fi`;
'hooks-system.telemetry.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
@@ -898,7 +869,7 @@ fi`;
it('should fire SessionStart hook on app startup', async () => {
// Create inline hook command that outputs JSON
const sessionStartCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session starting on startup\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
await rig.setup('should fire SessionStart hook on app startup', {
fakeResponsesPath: join(
@@ -906,10 +877,8 @@ fi`;
'hooks-system.session-startup.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
@@ -955,12 +924,172 @@ fi`;
}
});
it('should fire SessionStart hook and inject context', async () => {
// Create hook script that outputs JSON with additionalContext
const hookScript = `const fs = require('fs');
console.log(JSON.stringify({
decision: 'allow',
systemMessage: 'Context injected via SessionStart hook',
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: 'If asked about your identity, you must respond with the following: I am a protocol droid that speaks the bacchi.'
}
}));`;
await rig.setup('should fire SessionStart hook and inject context', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
});
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should fire SessionStart hook and inject context', {
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Run a query - the SessionStart hook will fire during app initialization
const result = await rig.run({ args: 'Who are you?' });
// Check if systemMessage was displayed (in stderr, which rig.run captures)
expect(result).toContain('Context injected via SessionStart hook');
// Check if additionalContext influenced the model response
// Note: We use fake responses, but the rig records interactions.
// If we are using fake responses, the model won't actually respond unless we provide a fake response for the injected context.
// But the test rig setup uses 'hooks-system.session-startup.responses'.
// If I'm adding a new test, I might need to generate new fake responses or expect the context to be sent to the model (verify API logs).
// Verify hook executed
const hookLogs = rig.readHookLogs();
const sessionStartLog = hookLogs.find(
(log) => log.hookCall.hook_event_name === 'SessionStart',
);
expect(sessionStartLog).toBeDefined();
// Verify the API request contained the injected context
// rig.readAllApiRequest() gives us telemetry on API requests.
const apiRequests = rig.readAllApiRequest();
// We expect at least one API request
expect(apiRequests.length).toBeGreaterThan(0);
// The injected context should be in the request text
// For non-interactive mode, I prepended it to input: "context\n\ninput"
// The telemetry `request_text` should contain it.
const requestText = apiRequests[0].attributes?.request_text || '';
expect(requestText).toContain('protocol droid');
});
it('should fire SessionStart hook and display systemMessage in interactive mode', async () => {
// Create hook script that outputs JSON with systemMessage and additionalContext
const hookScript = `const fs = require('fs');
console.log(JSON.stringify({
decision: 'allow',
systemMessage: 'Interactive Session Start Message',
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: 'The user is a Jedi Master.'
}
}));`;
await rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
},
);
const scriptPath = join(
rig.testDir!,
'session_start_interactive_hook.cjs',
);
writeFileSync(scriptPath, hookScript);
await rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
settings: {
hooks: {
enabled: true,
SessionStart: [
{
matcher: 'startup',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
const run = await rig.runInteractive();
// Verify systemMessage is displayed
await run.expectText('Interactive Session Start Message', 10000);
// Send a prompt to establish a session and trigger an API call
await run.sendKeys('Hello');
await run.type('\r');
// Wait for response to ensure API call happened
await run.expectText('Hello', 15000);
// Wait for telemetry to be written to disk
await rig.waitForTelemetryReady();
// Verify the API request contained the injected context
// We may need to poll for API requests as they are written asynchronously
const pollResult = await poll(
() => {
const apiRequests = rig.readAllApiRequest();
return apiRequests.length > 0;
},
15000,
500,
);
expect(pollResult).toBe(true);
const apiRequests = rig.readAllApiRequest();
// The injected context should be in the request_text of the API request
const requestText = apiRequests[0].attributes?.request_text || '';
expect(requestText).toContain('Jedi Master');
});
it('should fire SessionEnd and SessionStart hooks on /clear command', async () => {
// Create inline hook commands for both SessionEnd and SessionStart
const sessionEndCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session ending due to clear\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session ending due to clear'}))\"";
const sessionStartCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session starting after clear\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
await rig.setup(
'should fire SessionEnd and SessionStart hooks on /clear command',
@@ -970,10 +1099,8 @@ fi`;
'hooks-system.session-clear.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: '*',
@@ -1007,7 +1134,7 @@ fi`;
// Send an initial prompt to establish a session
await run.sendKeys('Say hello');
await run.sendKeys('\r');
await run.type('\r');
// Wait for the response
await run.expectText('Hello', 10000);
@@ -1017,14 +1144,14 @@ fi`;
const numClears = 3;
for (let i = 0; i < numClears; i++) {
await run.sendKeys('/clear');
await run.sendKeys('\r');
await run.type('\r');
// Wait a bit for clear to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
// Send a prompt to establish an active session before next clear
await run.sendKeys('Say hello');
await run.sendKeys('\r');
await run.type('\r');
// Wait for response
await run.expectText('Hello', 10000);
@@ -1136,7 +1263,7 @@ fi`;
it('should fire PreCompress hook on automatic compression', async () => {
// Create inline hook command that outputs JSON
const preCompressCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"PreCompress hook executed for automatic compression\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
await rig.setup('should fire PreCompress hook on automatic compression', {
fakeResponsesPath: join(
@@ -1144,10 +1271,8 @@ fi`;
'hooks-system.compress-auto.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
PreCompress: [
{
matcher: 'auto',
@@ -1203,7 +1328,7 @@ fi`;
describe('SessionEnd on Exit', () => {
it('should fire SessionEnd hook on graceful exit in non-interactive mode', async () => {
const sessionEndCommand =
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"SessionEnd hook executed on exit\\"}"';
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
await rig.setup('should fire SessionEnd hook on graceful exit', {
fakeResponsesPath: join(
@@ -1211,10 +1336,8 @@ fi`;
'hooks-system.session-startup.responses',
),
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
SessionEnd: [
{
matcher: 'exit',
@@ -1297,44 +1420,39 @@ fi`;
});
// Create two hook scripts - one enabled, one disabled
const enabledHookScript = `#!/bin/bash
echo '{"decision": "allow", "systemMessage": "Enabled hook executed"}'`;
const enabledHookScript = `const fs = require('fs');
console.log(JSON.stringify({decision: "allow", systemMessage: "Enabled hook executed"}));`;
const disabledHookScript = `#!/bin/bash
echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook should be disabled"}'`;
const disabledHookScript = `const fs = require('fs');
console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook should not execute", reason: "This hook should be disabled"}));`;
const enabledPath = join(rig.testDir!, 'enabled_hook.sh');
const disabledPath = join(rig.testDir!, 'disabled_hook.sh');
const enabledPath = join(rig.testDir!, 'enabled_hook.cjs');
const disabledPath = join(rig.testDir!, 'disabled_hook.cjs');
writeFileSync(enabledPath, enabledHookScript);
writeFileSync(disabledPath, disabledHookScript);
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${enabledPath}"`);
execSync(`chmod +x "${disabledPath}"`);
await rig.setup('should not execute hooks disabled in settings file', {
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
{
type: 'command',
command: enabledPath,
command: `node "${enabledPath}"`,
timeout: 5000,
},
{
type: 'command',
command: disabledPath,
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
disabled: [disabledPath], // Disable the second hook
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
});
@@ -1358,10 +1476,10 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
// Check hook telemetry - only enabled hook should have executed
const hookLogs = rig.readHookLogs();
const enabledHookLog = hookLogs.find(
(log) => log.hookCall.hook_name === enabledPath,
(log) => log.hookCall.hook_name === `node "${enabledPath}"`,
);
const disabledHookLog = hookLogs.find(
(log) => log.hookCall.hook_name === disabledPath,
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
);
expect(enabledHookLog).toBeDefined();
@@ -1380,46 +1498,41 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
);
// Create two hook scripts - one that will be disabled, one that won't
const activeHookScript = `#!/bin/bash
echo '{"decision": "allow", "systemMessage": "Active hook executed"}'`;
const activeHookScript = `const fs = require('fs');
console.log(JSON.stringify({decision: "allow", systemMessage: "Active hook executed"}));`;
const disabledHookScript = `#!/bin/bash
echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook is disabled"}'`;
const disabledHookScript = `const fs = require('fs');
console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook should not execute", reason: "This hook is disabled"}));`;
const activePath = join(rig.testDir!, 'active_hook.sh');
const disabledPath = join(rig.testDir!, 'disabled_hook.sh');
const activePath = join(rig.testDir!, 'active_hook.cjs');
const disabledPath = join(rig.testDir!, 'disabled_hook.cjs');
writeFileSync(activePath, activeHookScript);
writeFileSync(disabledPath, disabledHookScript);
const { execSync } = await import('node:child_process');
execSync(`chmod +x "${activePath}"`);
execSync(`chmod +x "${disabledPath}"`);
await rig.setup(
'should respect disabled hooks across multiple operations',
{
settings: {
tools: {
enableHooks: true,
},
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
{
type: 'command',
command: activePath,
command: `node "${activePath}"`,
timeout: 5000,
},
{
type: 'command',
command: disabledPath,
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
disabled: [disabledPath], // Disable the second hook
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
},
@@ -1441,10 +1554,10 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
// Check hook telemetry
const hookLogs1 = rig.readHookLogs();
const activeHookLog1 = hookLogs1.find(
(log) => log.hookCall.hook_name === activePath,
(log) => log.hookCall.hook_name === `node "${activePath}"`,
);
const disabledHookLog1 = hookLogs1.find(
(log) => log.hookCall.hook_name === disabledPath,
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
);
expect(activeHookLog1).toBeDefined();
@@ -1465,9 +1578,171 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
// Verify disabled hook still hasn't executed
const hookLogs2 = rig.readHookLogs();
const disabledHookCalls = hookLogs2.filter(
(log) => log.hookCall.hook_name === disabledPath,
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
);
expect(disabledHookCalls.length).toBe(0);
});
});
describe('BeforeTool Hooks - Input Override', () => {
it('should override tool input parameters via BeforeTool hook', async () => {
// 1. First setup to get the test directory and prepare the hook script
await rig.setup(
'should override tool input parameters via BeforeTool hook',
);
// Create a hook script that overrides the tool input
const hookOutput = {
decision: 'allow',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
tool_input: {
file_path: 'modified.txt',
content: 'modified content',
},
},
};
const hookScript = `process.stdout.write(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
const scriptPath = join(rig.testDir!, 'input_override_hook.js');
writeFileSync(scriptPath, hookScript);
// Ensure path is properly escaped for command line usage on all platforms
// On Windows, backslashes in the command string need to be handled carefully
// Using forward slashes works well with Node.js on all platforms
const commandPath = scriptPath.replace(/\\/g, '/');
// 2. Full setup with settings and fake responses
await rig.setup(
'should override tool input parameters via BeforeTool hook',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-modification.responses',
),
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
// Run the agent. The fake response will attempt to call write_file with
// file_path="original.txt" and content="original content"
await rig.run({
args: 'Create a file called original.txt with content "original content"',
});
// 1. Verify that 'modified.txt' was created with 'modified content' (Override successful)
const modifiedContent = rig.readFile('modified.txt');
expect(modifiedContent).toBe('modified content');
// 2. Verify that 'original.txt' was NOT created (Override replaced original)
let originalExists = false;
try {
rig.readFile('original.txt');
originalExists = true;
} catch {
originalExists = false;
}
expect(originalExists).toBe(false);
// 3. Verify hook telemetry
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
const hookLogs = rig.readHookLogs();
expect(hookLogs.length).toBe(1);
expect(hookLogs[0].hookCall.hook_name).toContain(
'input_override_hook.js',
);
// 4. Verify that the agent didn't try to work-around the hook input change
const toolLogs = rig.readToolLogs();
expect(toolLogs.length).toBe(1);
expect(toolLogs[0].toolRequest.name).toBe('write_file');
expect(JSON.parse(toolLogs[0].toolRequest.args).file_path).toBe(
'modified.txt',
);
});
});
describe('BeforeTool Hooks - Stop Execution', () => {
it('should stop agent execution via BeforeTool hook', async () => {
// Create a hook script that stops execution
const hookOutput = {
continue: false,
reason: 'Emergency Stop triggered by hook',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
await rig.setup('should stop agent execution via BeforeTool hook');
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
writeFileSync(scriptPath, hookScript);
const commandPath = scriptPath.replace(/\\/g, '/');
await rig.setup('should stop agent execution via BeforeTool hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-stop.responses',
),
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run({
args: 'Use write_file to create test.txt',
});
// The hook should have stopped execution message (returned from tool)
expect(result).toContain(
'Agent execution stopped: Emergency Stop triggered by hook',
);
// Tool should NOT be called successfully (it was blocked/stopped)
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
});
});
});
@@ -200,7 +200,7 @@ describe('mcp server with cyclic tool schema is detected', () => {
const run = await rig.runInteractive();
await run.type('/mcp list');
await run.sendKeys('\r');
await run.type('\r');
await run.expectText('tool_with_cyclic_schema');
});
+12 -2
View File
@@ -209,6 +209,12 @@ export class InteractiveRun {
async type(text: string) {
let typedSoFar = '';
for (const char of text) {
if (char === '\r') {
// wait >30ms before `enter` to avoid fast return conversion
// from bufferFastReturn() in KeypressContent.tsx
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.ptyProcess.write(char);
typedSoFar += char;
@@ -285,7 +291,9 @@ export class TestRig {
) {
this.testName = testName;
const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env['INTEGRATION_TEST_FILE_DIR']!, sanitizedName);
const testFileDir =
env['INTEGRATION_TEST_FILE_DIR'] || join(os.tmpdir(), 'gemini-cli-tests');
this.testDir = join(testFileDir, sanitizedName);
mkdirSync(this.testDir, { recursive: true });
if (options.fakeResponsesPath) {
this.fakeResponsesPath = join(this.testDir, 'fake-responses.json');
@@ -323,7 +331,9 @@ export class TestRig {
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
+55 -40
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -36,8 +36,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -4262,6 +4262,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -6095,7 +6102,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-buffer-byte-length": {
@@ -8865,6 +8871,19 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-headers": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-headers/-/eslint-plugin-headers-1.3.3.tgz",
"integrity": "sha512-VzZY4+cGRoR5HpALLARH+ibIjB6a2w12/cFEayORHXMRHMzDnweSjpmvxyzX3rsSIVCg01zmvepB7Tnmaj4kGQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-plugin-import": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
@@ -8919,16 +8938,6 @@
"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",
"integrity": "sha512-khTCz6G3JdoQfwrtY4XKl98KW4PpnWUKuFx8v+twIRhJADEyYglMDC0td8It75C1MZ88gcvMusWuUlJsos7gYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"requireindex": "^1.2.0"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -10938,9 +10947,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz",
"integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==",
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -11827,7 +11836,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -15069,16 +15077,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.5"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -18473,7 +18471,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18481,7 +18479,7 @@
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"uuid": "^11.1.0",
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
"bin": {
@@ -18769,21 +18767,21 @@
}
},
"packages/a2a-server/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -18802,7 +18800,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -18886,9 +18884,10 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
@@ -18921,6 +18920,7 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"js-yaml": "^4.1.1",
"marked": "^15.0.12",
"mime": "4.0.7",
"mnemonist": "^0.40.3",
@@ -18932,6 +18932,7 @@
"strip-ansi": "^7.1.0",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0",
"uuid": "^13.0.0",
"web-tree-sitter": "^0.25.10",
"zod": "^3.25.76"
},
@@ -18939,6 +18940,7 @@
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/diff": "^7.0.2",
"@types/fast-levenshtein": "^0.0.4",
"@types/js-yaml": "^4.0.9",
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"msw": "^2.3.4",
@@ -19024,9 +19026,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"packages/core/node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -19037,7 +19052,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.3"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -52,7 +52,7 @@
"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",
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",
"release:version": "node scripts/version.js",
@@ -62,7 +62,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -94,8 +94,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -121,7 +121,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -31,7 +31,7 @@
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"uuid": "^11.1.0",
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionsCommand } from './extensions.js';
import { InitCommand } from './init.js';
import { RestoreCommand } from './restore.js';
@@ -20,7 +21,7 @@ class CommandRegistry {
register(command: Command) {
if (this.commands.has(command.name)) {
console.warn(`Command ${command.name} already registered. Skipping.`);
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
import { debugLogger } from '@google/gemini-cli-core';
const mocks = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
@@ -75,7 +76,7 @@ describe('loadSettings', () => {
fs.rmSync(mockWorkspaceDir, { recursive: true, force: true });
}
} catch (e) {
console.error('Failed to cleanup temp dirs', e);
debugLogger.error('Failed to cleanup temp dirs', e);
}
vi.restoreAllMocks();
});
+2 -1
View File
@@ -35,7 +35,8 @@ import {
createStreamMessageRequest,
createMockConfig,
} from '../utils/testing_utils.js';
import { MockTool } from '@google/gemini-cli-core';
// Import MockTool from specific path to avoid vitest dependency in main core bundle
import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
import type { Command, CommandContext } from '../commands/types.js';
const mockToolConfirmationFn = async () =>
+2 -2
View File
@@ -25,7 +25,7 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { commandRegistry } from '../commands/command-registry.js';
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
import { GitService } from '@google/gemini-cli-core';
@@ -239,7 +239,7 @@ export async function createApp() {
): CommandResponse | undefined => {
const commandName = command.name;
if (visited.includes(commandName)) {
console.warn(
debugLogger.warn(
`Command ${commandName} already inserted in the response, skipping`,
);
return undefined;
@@ -59,7 +59,6 @@ export function createMockConfig(
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUserTier: vi.fn(),
getEnableMessageBusIntegration: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.24.3",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.3"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -45,7 +45,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.6",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -4,11 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type { ExtensionManager } from '../../config/extension-manager.js';
import type {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
@@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
@@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
};
});
@@ -72,12 +88,31 @@ describe('handleInstall', () => {
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
return {
source,
type: 'git',
ref: args?.ref,
autoUpdate: args?.autoUpdate,
allowPreRelease: args?.allowPreRelease,
};
}
return { source, type: 'local' };
});
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
});
@@ -124,7 +159,9 @@ describe('handleInstall', () => {
});
it('throws an error from an unknown source', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
mockInferInstallMetadata.mockRejectedValue(
new Error('Install source not found.'),
);
await handleInstall({
source: 'test://google.com',
});
+10 -36
View File
@@ -5,17 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -30,37 +29,12 @@ interface InstallArgs {
export async function handleInstall(args: InstallArgs) {
try {
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,
allowPreRelease: args.allowPreRelease,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
const installMetadata = await inferInstallMetadata(source, {
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
});
const requestConsent = args.consent
? () => Promise.resolve(true)
@@ -0,0 +1,231 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { settingsCommand } from './settings.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type { getExtensionAndManager } from './utils.js';
import type {
updateSetting,
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js';
import {
promptForSetting,
ExtensionSettingScope,
} from '../../config/extensions/extensionSettings.js';
import type { exitCli } from '../utils.js';
import type { ExtensionManager } from '../../config/extension-manager.js';
const mockGetExtensionAndManager: Mock<typeof getExtensionAndManager> =
vi.hoisted(() => vi.fn());
const mockUpdateSetting: Mock<typeof updateSetting> = vi.hoisted(() => vi.fn());
const mockGetScopedEnvContents: Mock<typeof getScopedEnvContents> = vi.hoisted(
() => vi.fn(),
);
const mockExitCli: Mock<typeof exitCli> = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionAndManager: mockGetExtensionAndManager,
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
updateSetting: mockUpdateSetting,
promptForSetting: vi.fn(),
ExtensionSettingScope: {
USER: 'user',
WORKSPACE: 'workspace',
},
getScopedEnvContents: mockGetScopedEnvContents,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../utils.js', () => ({
exitCli: mockExitCli,
}));
describe('settings command', () => {
let debugLogSpy: Mock;
let debugErrorSpy: Mock;
beforeEach(() => {
debugLogSpy = debugLogger.log as Mock;
debugErrorSpy = debugLogger.error as Mock;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('set command', () => {
it('should log error and exit if extension is not found', async () => {
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar');
expect(mockExitCli).toHaveBeenCalled();
});
it('should log error and exit if extension config is not found', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue(null),
} as unknown as ExtensionManager;
mockGetExtensionAndManager.mockResolvedValue({
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar');
expect(debugErrorSpy).toHaveBeenCalledWith(
'Could not find configuration for extension "foo".',
);
expect(mockExitCli).toHaveBeenCalled();
});
it('should call updateSetting with correct arguments', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({}),
} as unknown as ExtensionManager;
const extension = { path: '/path/to/ext', id: 'ext-id' };
mockGetExtensionAndManager.mockResolvedValue({
extension: extension as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([])
.command(settingsCommand)
.parseAsync('settings set foo bar --scope workspace');
expect(mockUpdateSetting).toHaveBeenCalledWith(
{},
'ext-id',
'bar',
promptForSetting,
ExtensionSettingScope.WORKSPACE,
);
expect(mockExitCli).toHaveBeenCalled();
});
});
describe('list command', () => {
it('should log error and exit if extension is not found', async () => {
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(mockExitCli).toHaveBeenCalled();
});
it('should log message and exit if extension has no settings', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({ settings: [] }),
} as unknown as ExtensionManager;
mockGetExtensionAndManager.mockResolvedValue({
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "foo" has no settings to configure.',
);
expect(mockExitCli).toHaveBeenCalled();
});
it('should list settings correctly', async () => {
const mockExtensionManager = {
loadExtensionConfig: vi.fn().mockResolvedValue({
settings: [
{
name: 'Setting 1',
envVar: 'SETTING_1',
description: 'Desc 1',
sensitive: false,
},
{
name: 'Setting 2',
envVar: 'SETTING_2',
description: 'Desc 2',
sensitive: true,
},
{
name: 'Setting 3',
envVar: 'SETTING_3',
description: 'Desc 3',
sensitive: false,
},
],
}),
} as unknown as ExtensionManager;
const extension = { path: '/path/to/ext', id: 'ext-id' };
mockGetExtensionAndManager.mockResolvedValue({
extension: extension as unknown as GeminiCLIExtension,
extensionManager: mockExtensionManager,
});
mockGetScopedEnvContents.mockImplementation((_config, _id, scope) => {
if (scope === ExtensionSettingScope.USER) {
return Promise.resolve({
SETTING_1: 'val1',
SETTING_2: 'val2',
});
}
if (scope === ExtensionSettingScope.WORKSPACE) {
return Promise.resolve({
SETTING_3: 'val3',
});
}
return Promise.resolve({});
});
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
expect(debugLogSpy).toHaveBeenCalledWith('Settings for "foo":');
// Setting 1 (User)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 1 (SETTING_1)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 1');
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val1 (user)');
// Setting 2 (Sensitive)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 2 (SETTING_2)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 2');
expect(debugLogSpy).toHaveBeenCalledWith(
' Value: [value stored in keychain] (user)',
);
// Setting 3 (Workspace)
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 3 (SETTING_3)');
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 3');
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val3 (workspace)');
expect(mockExitCli).toHaveBeenCalled();
});
});
});
@@ -47,6 +47,7 @@ const setCommand: CommandModule<object, SetArgs> = {
const { name, setting, scope } = args;
const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) {
await exitCli();
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
@@ -56,6 +57,7 @@ const setCommand: CommandModule<object, SetArgs> = {
debugLogger.error(
`Could not find configuration for extension "${name}".`,
);
await exitCli();
return;
}
await updateSetting(
@@ -87,6 +89,7 @@ const listCommand: CommandModule<object, ListArgs> = {
const { name } = args;
const { extension, extensionManager } = await getExtensionAndManager(name);
if (!extension || !extensionManager) {
await exitCli();
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
@@ -98,6 +101,7 @@ const listCommand: CommandModule<object, ListArgs> = {
extensionConfig.settings.length === 0
) {
debugLogger.log(`Extension "${name}" has no settings to configure.`);
await exitCli();
return;
}
@@ -5,7 +5,15 @@
*/
import * as fs from 'node:fs';
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { handleValidate, validateCommand } from './validate.js';
import yargs from 'yargs';
import { createExtension } from '../../test-utils/createExtension.js';
@@ -512,7 +512,7 @@ describe('migrate command', () => {
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
});
});
+1 -1
View File
@@ -243,7 +243,7 @@ export async function handleMigrateFromClaude() {
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
+14 -1
View File
@@ -59,10 +59,23 @@ async function testMCPConnection(
version: '0.0.1',
});
const settings = loadSettings();
const sanitizationConfig = {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables:
settings.merged.advanced?.excludedEnvVars || [],
};
let transport;
try {
// Use the same transport creation logic as core
transport = await createTransport(serverName, config, false);
transport = await createTransport(
serverName,
config,
false,
sanitizationConfig,
);
} catch (_error) {
await client.close();
return MCPServerStatus.DISCONNECTED;
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { skillsCommand } from './skills.js';
vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } }));
vi.mock('./skills/enable.js', () => ({
enableCommand: { command: 'enable <name>' },
}));
vi.mock('./skills/disable.js', () => ({
disableCommand: { command: 'disable <name>' },
}));
vi.mock('../gemini.js', () => ({
initializeOutputListenersAndFlush: vi.fn(),
}));
describe('skillsCommand', () => {
it('should have correct command and aliases', () => {
expect(skillsCommand.command).toBe('skills <command>');
expect(skillsCommand.aliases).toEqual(['skill']);
expect(skillsCommand.describe).toBe('Manage agent skills.');
});
it('should register all subcommands in builder', () => {
const mockYargs = {
middleware: vi.fn().mockReturnThis(),
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
// @ts-expect-error - Mocking yargs
skillsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'enable <name>',
});
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'disable <name>',
});
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
it('should have a handler that does nothing', () => {
// @ts-expect-error - Handler doesn't take arguments in this case
expect(skillsCommand.handler()).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { listCommand } from './skills/list.js';
import { enableCommand } from './skills/enable.js';
import { disableCommand } from './skills/disable.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const skillsCommand: CommandModule = {
command: 'skills <command>',
aliases: ['skill'],
describe: 'Manage agent skills.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(listCommand)
.command(enableCommand)
.command(disableCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { handleDisable, disableCommand } from './disable.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../../config/settings.js';
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger,
};
});
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('skills disable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleDisable', () => {
it('should disable an enabled skill in user scope', async () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: [] } },
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleDisable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'skills.disabled',
['skill1'],
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" successfully disabled in scope "User".',
);
});
it('should log a message if the skill is already disabled', async () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: ['skill1'] } },
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleDisable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" is already disabled in scope "User".',
);
});
});
describe('disableCommand', () => {
it('should have correct command and describe', () => {
expect(disableCommand.command).toBe('disable <name>');
expect(disableCommand.describe).toBe('Disables an agent skill.');
});
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import {
loadSettings,
SettingScope,
type LoadableSettingScope,
} from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
interface DisableArgs {
name: string;
scope: LoadableSettingScope;
}
export async function handleDisable(args: DisableArgs) {
const { name, scope } = args;
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir);
const currentDisabled =
settings.forScope(scope).settings.skills?.disabled || [];
if (currentDisabled.includes(name)) {
debugLogger.log(`Skill "${name}" is already disabled in scope "${scope}".`);
return;
}
const newDisabled = [...currentDisabled, name];
settings.setValue(scope, 'skills.disabled', newDisabled);
debugLogger.log(`Skill "${name}" successfully disabled in scope "${scope}".`);
}
export const disableCommand: CommandModule = {
command: 'disable <name>',
describe: 'Disables an agent skill.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the skill to disable.',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'The scope to disable the skill in (user or project).',
type: 'string',
default: 'user',
choices: ['user', 'project'],
}),
handler: async (argv) => {
const scope: LoadableSettingScope =
argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User;
await handleDisable({
name: argv['name'] as string,
scope,
});
await exitCli();
},
};
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { handleEnable, enableCommand } from './enable.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../../config/settings.js';
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger,
};
});
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('skills enable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleEnable', () => {
it('should enable a disabled skill in user scope', async () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: ['skill1'] } },
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleEnable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'skills.disabled',
[],
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" successfully enabled in scope "User".',
);
});
it('should log a message if the skill is already enabled', async () => {
const mockSettings = {
forScope: vi.fn().mockReturnValue({
settings: { skills: { disabled: [] } },
}),
setValue: vi.fn(),
};
mockLoadSettings.mockReturnValue(
mockSettings as unknown as LoadedSettings,
);
await handleEnable({
name: 'skill1',
scope: SettingScope.User as LoadableSettingScope,
});
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Skill "skill1" is already enabled in scope "User".',
);
});
});
describe('enableCommand', () => {
it('should have correct command and describe', () => {
expect(enableCommand.command).toBe('enable <name>');
expect(enableCommand.describe).toBe('Enables an agent skill.');
});
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import {
loadSettings,
SettingScope,
type LoadableSettingScope,
} from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
interface EnableArgs {
name: string;
scope: LoadableSettingScope;
}
export async function handleEnable(args: EnableArgs) {
const { name, scope } = args;
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir);
const currentDisabled =
settings.forScope(scope).settings.skills?.disabled || [];
const newDisabled = currentDisabled.filter((d) => d !== name);
if (currentDisabled.length === newDisabled.length) {
debugLogger.log(`Skill "${name}" is already enabled in scope "${scope}".`);
return;
}
settings.setValue(scope, 'skills.disabled', newDisabled);
debugLogger.log(`Skill "${name}" successfully enabled in scope "${scope}".`);
}
export const enableCommand: CommandModule = {
command: 'enable <name>',
describe: 'Enables an agent skill.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the skill to enable.',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'The scope to enable the skill in (user or project).',
type: 'string',
default: 'user',
choices: ['user', 'project'],
}),
handler: async (argv) => {
const scope: LoadableSettingScope =
argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User;
await handleEnable({
name: argv['name'] as string,
scope,
});
await exitCli();
},
};
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { handleList, listCommand } from './list.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { Config } from '@google/gemini-cli-core';
import chalk from 'chalk';
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/settings.js');
vi.mock('../../config/config.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('skills list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockLoadCliConfig = vi.mocked(loadCliConfig);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleList', () => {
it('should log a message if no skills are discovered', async () => {
const mockConfig = {
initialize: vi.fn().mockResolvedValue(undefined),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
}),
};
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'No skills discovered.',
);
});
it('should list all discovered skills', async () => {
const skills = [
{
name: 'skill1',
description: 'desc1',
disabled: false,
location: '/path/to/skill1',
},
{
name: 'skill2',
description: 'desc2',
disabled: true,
location: '/path/to/skill2',
},
];
const mockConfig = {
initialize: vi.fn().mockResolvedValue(undefined),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue(skills),
}),
};
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
chalk.bold('Discovered Agent Skills:'),
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('skill1'),
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining(chalk.green('[Enabled]')),
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining('skill2'),
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining(chalk.red('[Disabled]')),
);
});
it('should throw an error when listing fails', async () => {
mockLoadCliConfig.mockRejectedValue(new Error('List failed'));
await expect(handleList()).rejects.toThrow('List failed');
});
});
describe('listCommand', () => {
const command = listCommand;
it('should have correct command and describe', () => {
expect(command.command).toBe('list');
expect(command.describe).toBe('Lists discovered agent skills.');
});
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { loadSettings } from '../../config/settings.js';
import { loadCliConfig, type CliArgs } from '../../config/config.js';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
export async function handleList() {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir);
const config = await loadCliConfig(
settings.merged,
'skills-list-session',
{
debug: false,
} as Partial<CliArgs> as CliArgs,
{ cwd: workspaceDir },
);
// Initialize to trigger extension loading and skill discovery
await config.initialize();
const skillManager = config.getSkillManager();
const skills = skillManager.getAllSkills();
if (skills.length === 0) {
debugLogger.log('No skills discovered.');
return;
}
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
debugLogger.log('');
for (const skill of skills) {
const status = skill.disabled
? chalk.red('[Disabled]')
: chalk.green('[Enabled]');
debugLogger.log(`${chalk.bold(skill.name)} ${status}`);
debugLogger.log(` Description: ${skill.description}`);
debugLogger.log(` Location: ${skill.location}`);
debugLogger.log('');
}
}
export const listCommand: CommandModule = {
command: 'list',
describe: 'Lists discovered agent skills.',
builder: (yargs) => yargs,
handler: async () => {
await handleList();
await exitCli();
},
};
+231 -13
View File
@@ -16,6 +16,7 @@ import {
WEB_FETCH_TOOL_NAME,
type ExtensionLoader,
debugLogger,
ApprovalMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
@@ -25,13 +26,11 @@ import { ExtensionManager } from './extension-manager.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
.fn()
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted
}));
vi.mock('./sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn().mockResolvedValue(undefined),
loadSandboxConfig: vi.fn(async () => undefined),
}));
vi.mock('fs', async (importOriginal) => {
@@ -125,6 +124,12 @@ vi.mock('@google/gemini-cli-core', async () => {
respectGitIgnore: true,
respectGeminiIgnore: true,
},
createPolicyEngineConfig: vi.fn(async () => ({
rules: [],
checkers: [],
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
};
});
@@ -631,14 +636,7 @@ describe('loadCliConfig', () => {
expect(config.getFileFilteringRespectGeminiIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
);
});
it('should default enableMessageBusIntegration to true when unconfigured', 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['enableMessageBusIntegration']).toBe(true);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
});
@@ -1071,7 +1069,7 @@ describe('Approval mode tool exclusion logic', () => {
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode when it is disabled by settings',
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
@@ -2325,3 +2323,223 @@ describe('Telemetry configuration via environment variables', () => {
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
});
});
describe('PolicyEngine nonInteractive wiring', () => {
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should set nonInteractive to true in one-shot mode', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, 'test-session', argv);
expect(config.isInteractive()).toBe(false);
expect(
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
.nonInteractive,
).toBe(true);
});
it('should set nonInteractive to false in interactive mode', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, 'test-session', argv);
expect(config.isInteractive()).toBe(true);
expect(
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
.nonInteractive,
).toBe(false);
});
});
describe('Policy Engine Integration in loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => {
process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool'];
const settings: Settings = { tools: { allowed: ['settings-tool'] } };
const argv = await parseArguments({} as Settings);
await loadCliConfig(settings, 'test-session', argv);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: expect.arrayContaining(['cli-tool']),
}),
}),
expect.anything(),
);
});
it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => {
process.stdin.isTTY = false; // Non-interactive to trigger default excludes
process.argv = ['node', 'script.js', '-p', 'test'];
const settings: Settings = { tools: { exclude: ['settings-exclude'] } };
const argv = await parseArguments({} as Settings);
await loadCliConfig(settings, 'test-session', argv);
// In non-interactive mode, ShellTool, etc. are excluded
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
exclude: expect.arrayContaining([SHELL_TOOL_NAME]),
}),
}),
expect.anything(),
);
});
});
describe('loadCliConfig secureModeEnabled', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js', '--approval-mode=yolo'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Cannot start in YOLO mode since it is disabled by your admin',
);
});
it('should set disableYoloMode to true when secureModeEnabled is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
admin: {
secureModeEnabled: true,
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isYoloModeDisabled()).toBe(true);
});
});
describe('loadCliConfig mcpEnabled', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
const mcpSettings = {
mcp: {
serverCommand: 'mcp-server',
allowed: ['serverA'],
excluded: ['serverB'],
},
mcpServers: { serverA: { url: 'http://a' } },
};
it('should enable MCP by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { ...mcpSettings };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(true);
expect(config.getMcpServerCommand()).toBe('mcp-server');
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
it('should disable MCP when mcpEnabled is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
...mcpSettings,
admin: {
mcp: {
enabled: false,
},
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(false);
expect(config.getMcpServerCommand()).toBeUndefined();
expect(config.getMcpServers()).toEqual({});
expect(config.getAllowedMcpServers()).toEqual([]);
expect(config.getBlockedMcpServers()).toEqual([]);
});
it('should enable MCP when mcpEnabled is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
...mcpSettings,
admin: {
mcp: {
enabled: true,
},
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getMcpEnabled()).toBe(true);
expect(config.getMcpServerCommand()).toBe('mcp-server');
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
});
+95 -35
View File
@@ -8,8 +8,8 @@ import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import type { OutputFormat } from '@google/gemini-cli-core';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
import { hooksCommand } from '../commands/hooks.js';
import {
Config,
@@ -33,8 +33,12 @@ import {
WEB_FETCH_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { saveModelChange, loadSettings } from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
@@ -49,6 +53,7 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
import { getEnableHooks } from './settingsSchema.js';
export interface CliArgs {
query: string | undefined;
@@ -70,7 +75,6 @@ export interface CliArgs {
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
useSmartEdit: boolean | undefined;
useWriteTodos: boolean | undefined;
outputFormat: string | undefined;
fakeResponses: string | undefined;
@@ -283,8 +287,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
yargsInstance.command(extensionsCommand);
}
if (settings?.experimental?.skills ?? false) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings?.tools?.enableHooks) {
if (getEnableHooks(settings)) {
yargsInstance.command(hooksCommand);
}
@@ -379,14 +387,24 @@ export function isDebugMode(argv: CliArgs): boolean {
);
}
export interface LoadCliConfigOptions {
cwd?: string;
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
}
export async function loadCliConfig(
settings: Settings,
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
options: LoadCliConfigOptions = {},
): Promise<Config> {
const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv);
const loadedSettings = loadSettings(cwd);
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
}
@@ -396,7 +414,7 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
@@ -487,11 +505,19 @@ export async function loadCliConfig(
}
// Override approval mode if disableYoloMode is set.
if (settings.security?.disableYoloMode) {
if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) {
if (approvalMode === ApprovalMode.YOLO) {
debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.');
if (settings.admin?.secureModeEnabled) {
debugLogger.error(
'YOLO mode is disabled by "secureModeEnabled" setting.',
);
} else {
debugLogger.error(
'YOLO mode is disabled by the "disableYolo" setting.',
);
}
throw new FatalConfigError(
'Cannot start in YOLO mode when it is disabled by settings',
'Cannot start in YOLO mode since it is disabled by your admin',
);
}
approvalMode = ApprovalMode.DEFAULT;
@@ -524,23 +550,16 @@ export async function loadCliConfig(
throw err;
}
const policyEngineConfig = await createPolicyEngineConfig(
settings,
approvalMode,
);
const enableMessageBusIntegration =
settings.tools?.enableMessageBusIntegration ?? true;
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
@@ -580,6 +599,26 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined,
);
// Create a settings object that includes CLI overrides for policy generation
const effectiveSettings: Settings = {
...settings,
tools: {
...settings.tools,
allowed: allowedTools,
exclude: excludeTools,
},
mcp: {
...settings.mcp,
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
},
};
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
);
policyEngineConfig.nonInteractive = !interactive;
const defaultModel = settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL_AUTO;
@@ -597,6 +636,8 @@ export async function loadCliConfig(
const ptyInfo = await getPty();
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -615,24 +656,34 @@ export async function loadCliConfig(
excludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
mcpServers: settings.mcpServers,
allowedMcpServers: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
blockedMcpServers: argv.allowedMcpServerNames
? [] // explicitly allowed servers overrides everything
: settings.mcp?.excluded,
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
mcpServers: mcpEnabled ? settings.mcpServers : {},
mcpEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
: undefined,
blockedMcpServers: mcpEnabled
? argv.allowedMcpServerNames
? undefined
: settings.mcp?.excluded
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
enableEnvironmentVariableRedaction:
settings.security?.environmentVariableRedaction?.enabled,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
geminiMdFilePaths: filePaths,
approvalMode,
disableYoloMode: settings.security?.disableYoloMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
showMemoryUsage: settings.ui?.showMemoryUsage || false,
accessibility: {
...settings.ui?.accessibility,
screenReader,
},
telemetry: telemetrySettings,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled,
fileFiltering,
checkpointing: settings.general?.checkpointing?.enabled,
proxy:
@@ -644,7 +695,7 @@ export async function loadCliConfig(
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
@@ -653,6 +704,8 @@ export async function loadCliConfig(
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
skillsSupport: settings.experimental?.skills,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
@@ -662,33 +715,40 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell:
settings.tools?.shell?.enableInteractiveShell ?? true,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
enablePromptCompletion: settings.general?.enablePromptCompletion,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
enableMessageBusIntegration,
codebaseInvestigatorSettings:
settings.experimental?.codebaseInvestigatorSettings,
introspectionAgentSettings:
settings.experimental?.introspectionAgentSettings,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors ?? false,
retryFetchErrors: settings.general?.retryFetchErrors,
ptyInfo: ptyInfo?.name,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.tools?.enableHooks ?? false,
enableHooks: getEnableHooks(settings),
hooks: settings.hooks || {},
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
disabledSkills: refreshedSettings.merged.skills?.disabled,
};
},
});
}
@@ -0,0 +1,138 @@
/**
* @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 path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { loadSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
import { coreEvents } from '@google/gemini-cli-core';
const mockHomedir = vi.hoisted(() => vi.fn());
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof import('node:os')>();
return {
...mockedOs,
homedir: mockHomedir,
};
});
describe('ExtensionManager skills validation', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(userExtensionsDir, { recursive: true });
mockHomedir.mockReturnValue(tempHomeDir);
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn().mockResolvedValue(''),
settings: loadSettings(tempWorkspaceDir).merged,
});
vi.spyOn(coreEvents, 'emitFeedback');
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(sourceExtDir, 'skills');
fs.mkdirSync(skillsDir);
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
await extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(extension.name).toBe('skills-ext');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
});
it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'load-skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(extDir, 'skills');
fs.mkdirSync(skillsDir);
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
await extensionManager.loadExtensions();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'The directory is not empty but no valid skills were discovered',
),
);
});
it('should succeed if skills are correctly loaded', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'good-skills-ext',
version: '1.0.0',
});
const skillsDir = path.join(sourceExtDir, 'skills');
const skillSubdir = path.join(skillsDir, 'test-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test desc\n---\nbody',
);
await extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(extension.skills).toHaveLength(1);
expect(extension.skills![0].name).toBe('test-skill');
// It might be called for other reasons during startup, but shouldn't be called for our skills loading success
// Actually, it shouldn't be called with our warning message
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'warning',
expect.stringContaining('Failed to load skills from'),
);
});
});
+116 -27
View File
@@ -7,6 +7,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type Settings, SettingScope } from './settings.js';
@@ -37,12 +38,15 @@ import {
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
loadSkillsFromDir,
type ExtensionEvents,
type MCPServerConfig,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
type HookDefinition,
type HookEventName,
type ResolvedExtensionSetting,
coreEvents,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -56,9 +60,11 @@ import {
import {
getEnvContents,
maybePromptForSettings,
getMissingSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { getEnableHooks } from './settingsSchema.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -198,7 +204,9 @@ export class ExtensionManager extends ExtensionLoader {
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await this.requestConsent(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
Would you like to attempt to install via "git clone" instead?`,
))
) {
await cloneFromGit(installMetadata, tempDir);
@@ -222,25 +230,6 @@ export class ExtensionManager extends ExtensionLoader {
try {
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
if (isUpdate && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual && installMetadata.autoUpdate) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
@@ -258,10 +247,17 @@ export class ExtensionManager extends ExtensionLoader {
const newHasHooks = fs.existsSync(
path.join(localSourcePath, 'hooks', 'hooks.json'),
);
let previousHasHooks = false;
if (isUpdate && previous && previous.hooks) {
previousHasHooks = Object.keys(previous.hooks).length > 0;
}
const previousHasHooks = !!(
isUpdate &&
previous &&
previous.hooks &&
Object.keys(previous.hooks).length > 0
);
const newSkills = await loadSkillsFromDir(
path.join(localSourcePath, 'skills'),
);
const previousSkills = previous?.skills ?? [];
await maybeRequestConsentOrFail(
newExtensionConfig,
@@ -269,6 +265,8 @@ export class ExtensionManager extends ExtensionLoader {
newHasHooks,
previousExtensionConfig,
previousHasHooks,
newSkills,
previousSkills,
);
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
const destinationPath = new ExtensionStorage(
@@ -302,6 +300,20 @@ export class ExtensionManager extends ExtensionLoader {
}
}
const missingSettings = await getMissingSettings(
newExtensionConfig,
extensionId,
);
if (missingSettings.length > 0) {
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
.map((s) => s.name)
.join(
', ',
)}. Please run "gemini extensions settings ${newExtensionConfig.name} <setting-name>" to configure them.`;
debugLogger.warn(message);
coreEvents.emitFeedback('warning', message);
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
@@ -506,6 +518,24 @@ export class ExtensionManager extends ExtensionLoader {
);
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
resolvedSettings.push({
name: setting.name,
envVar: setting.envVar,
value:
value === undefined
? '[not set]'
: setting.sensitive
? '***'
: value,
sensitive: setting.sensitive ?? false,
});
}
}
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
@@ -522,14 +552,18 @@ export class ExtensionManager extends ExtensionLoader {
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (this.settings.tools?.enableHooks) {
if (getEnableHooks(this.settings)) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
});
}
const extension = {
const skills = await loadSkillsFromDir(
path.join(effectiveExtensionPath, 'skills'),
);
const extension: GeminiCLIExtension = {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
@@ -543,6 +577,9 @@ export class ExtensionManager extends ExtensionLoader {
this.workspaceDir,
),
id: getExtensionId(config, installMetadata),
settings: config.settings,
resolvedSettings,
skills,
};
this.loadedExtensions = [...this.loadedExtensions, extension];
@@ -697,6 +734,19 @@ export class ExtensionManager extends ExtensionLoader {
output += `\n ${tool}`;
});
}
if (extension.skills && extension.skills.length > 0) {
output += `\n Agent skills:`;
extension.skills.forEach((skill) => {
output += `\n ${skill.name}: ${skill.description}`;
});
}
const resolvedSettings = extension.resolvedSettings;
if (resolvedSettings && resolvedSettings.length > 0) {
output += `\n Settings:`;
resolvedSettings.forEach((setting) => {
output += `\n ${setting.name}: ${setting.value}`;
});
}
return output;
}
@@ -797,7 +847,46 @@ function validateName(name: string) {
}
}
function getExtensionId(
export async function inferInstallMetadata(
source: string,
args: {
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
} = {},
): Promise<ExtensionInstallMetadata> {
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
return {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
return {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
}
export function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {
+17 -14
View File
@@ -750,8 +750,8 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.tools) settings.tools = {};
settings.tools.enableHooks = true;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -771,7 +771,7 @@ describe('extension tests', () => {
);
});
it('should not load hooks if enableHooks is false', async () => {
it('should not load hooks if hooks.enabled is false', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'hook-extension-disabled',
@@ -786,8 +786,8 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.tools) settings.tools = {};
settings.tools.enableHooks = false;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -1447,7 +1447,7 @@ This extension will run the following MCP servers:
expect(envContent).toContain('NEW_SETTING=new-setting-value');
});
it('should fail auto-update if settings have changed', async () => {
it('should auto-update if settings have changed', async () => {
// 1. Install initial version with autoUpdate: true
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir,
@@ -1469,7 +1469,7 @@ This extension will run the following MCP servers:
});
// 2. Create new version with different settings
const newSourceExtDir = createExtension({
const extensionDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.1.0',
@@ -1488,14 +1488,17 @@ This extension will run the following MCP servers:
);
// 3. Attempt to update and assert it fails
await expect(
extensionManager.installOrUpdateExtension(
{ source: newSourceExtDir, type: 'local', autoUpdate: true },
previousExtensionConfig,
),
).rejects.toThrow(
'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.',
const updatedExtension = await extensionManager.installOrUpdateExtension(
{
source: extensionDir,
type: 'local',
autoUpdate: true,
},
previousExtensionConfig,
);
expect(updatedExtension.version).toBe('1.1.0');
expect(extensionManager.getExtensions()[0].version).toBe('1.1.0');
});
it('should throw an error for invalid extension names', async () => {
@@ -5,15 +5,20 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import chalk from 'chalk';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
INSTALL_WARNING_MESSAGE,
SKILLS_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
const mockReadline = vi.hoisted(() => ({
createInterface: vi.fn().mockReturnValue({
@@ -22,12 +27,26 @@ const mockReadline = vi.hoisted(() => ({
}),
}));
const mockReaddir = vi.hoisted(() => vi.fn());
const originalReaddir = vi.hoisted(() => ({
current: null as typeof fs.readdir | null,
}));
// Mocking readline for non-interactive prompts
vi.mock('node:readline', () => ({
default: mockReadline,
createInterface: mockReadline.createInterface,
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
originalReaddir.current = actual.readdir;
return {
...actual,
readdir: mockReaddir,
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -40,11 +59,22 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
describe('consent', () => {
beforeEach(() => {
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
if (originalReaddir.current) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockReaddir.mockImplementation(originalReaddir.current as any);
}
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-'));
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
describe('requestConsentNonInteractive', () => {
@@ -250,6 +280,105 @@ describe('consent', () => {
);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if skills change', async () => {
const skill1Dir = path.join(tempDir, 'skill1');
const skill2Dir = path.join(tempDir, 'skill2');
await fs.mkdir(skill1Dir, { recursive: true });
await fs.mkdir(skill2Dir, { recursive: true });
await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra');
await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2');
const skill1: SkillDefinition = {
name: 'skill1',
description: 'desc1',
location: path.join(skill1Dir, 'SKILL.md'),
body: 'body1',
};
const skill2: SkillDefinition = {
name: 'skill2',
description: 'desc2',
location: path.join(skill2Dir, 'SKILL.md'),
body: 'body2',
};
const config: ExtensionConfig = {
...baseConfig,
mcpServers: {
server1: { command: 'npm', args: ['start'] },
server2: { httpUrl: 'https://remote.com' },
},
contextFileName: 'my-context.md',
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
config,
requestConsent,
false,
undefined,
false,
[skill1, skill2],
);
const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will run the following MCP servers:',
' * server1 (local): npm start',
' * server2 (remote): https://remote.com',
'This extension will append info to your gemini.md context using my-context.md',
'This extension will exclude the following core tools: tool1,tool2',
'',
chalk.bold('Agent Skills:'),
SKILLS_WARNING_MESSAGE,
'This extension will install the following agent skills:',
` * ${chalk.bold('skill1')}: desc1`,
` (Location: ${skill1.location}) (2 items in directory)`,
` * ${chalk.bold('skill2')}: desc2`,
` (Location: ${skill2.location}) (1 items in directory)`,
'',
].join('\n');
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
it('should show a warning if the skill directory cannot be read', async () => {
const lockedDir = path.join(tempDir, 'locked');
await fs.mkdir(lockedDir, { recursive: true });
const skill: SkillDefinition = {
name: 'locked-skill',
description: 'A skill in a locked dir',
location: path.join(lockedDir, 'SKILL.md'),
body: 'body',
};
// Mock readdir to simulate a permission error.
// We do this instead of using fs.mkdir(..., { mode: 0o000 }) because
// directory permissions work differently on Windows and 0o000 doesn't
// effectively block access there, leading to test failures in Windows CI.
mockReaddir.mockRejectedValueOnce(
new Error('EACCES: permission denied, scandir'),
);
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
undefined,
false,
[skill],
);
expect(requestConsent).toHaveBeenCalledWith(
expect.stringContaining(
` (Location: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`,
),
);
});
});
});
});
+43 -9
View File
@@ -4,14 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
import chalk from 'chalk';
import type { ConfirmationRequest } from '../../ui/types.js';
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
import type { ExtensionConfig } from '../extension.js';
export const INSTALL_WARNING_MESSAGE =
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
export const INSTALL_WARNING_MESSAGE = chalk.yellow(
'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.',
);
export const SKILLS_WARNING_MESSAGE = chalk.yellow(
"Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.",
);
/**
* Requests consent from the user to perform an action, by reading a Y/n
@@ -38,7 +46,7 @@ export async function requestConsentNonInteractive(
* 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.
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive(
@@ -82,7 +90,7 @@ async function promptForConsentNonInteractive(
* 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.
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive(
@@ -103,10 +111,11 @@ async function promptForConsentInteractive(
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(
async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
): string {
skills: SkillDefinition[] = [],
): Promise<string> {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
@@ -138,6 +147,24 @@ function extensionConsentString(
'⚠️ This extension contains Hooks which can automatically execute commands.',
);
}
if (skills.length > 0) {
output.push(`\n${chalk.bold('Agent Skills:')}`);
output.push(SKILLS_WARNING_MESSAGE);
output.push('This extension will install the following agent skills:');
for (const skill of skills) {
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
const skillDir = path.dirname(skill.location);
let fileCountStr = '';
try {
const skillDirItems = await fs.readdir(skillDir);
fileCountStr = ` (${skillDirItems.length} items in directory)`;
} catch {
fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`;
}
output.push(` (Location: ${skill.location})${fileCountStr}`);
}
output.push('');
}
return output.join('\n');
}
@@ -156,12 +183,19 @@ export async function maybeRequestConsentOrFail(
hasHooks: boolean,
previousExtensionConfig?: ExtensionConfig,
previousHasHooks?: boolean,
skills: SkillDefinition[] = [],
previousSkills: SkillDefinition[] = [],
) {
const extensionConsent = extensionConsentString(extensionConfig, hasHooks);
const extensionConsent = await extensionConsentString(
extensionConfig,
hasHooks,
skills,
);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
const previousExtensionConsent = await extensionConsentString(
previousExtensionConfig,
previousHasHooks ?? false,
previousSkills,
);
if (previousExtensionConsent === extensionConsent) {
return;
@@ -298,3 +298,24 @@ async function clearSettings(
}
return;
}
export async function getMissingSettings(
extensionConfig: ExtensionConfig,
extensionId: string,
): Promise<ExtensionSetting[]> {
const { settings } = extensionConfig;
if (!settings || settings.length === 0) {
return [];
}
const existingSettings = await getEnvContents(extensionConfig, extensionId);
const missingSettings: ExtensionSetting[] = [];
for (const setting of settings) {
if (existingSettings[setting.envVar] === undefined) {
missingSettings.push(setting);
}
}
return missingSettings;
}
@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import {
KeychainTokenStorage,
debugLogger,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
return {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
KeychainTokenStorage: vi.fn(),
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
on: vi.fn(),
off: vi.fn(),
},
};
});
// Mock os.homedir because ExtensionStorage uses it
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
vi.clearAllMocks();
mockKeychainData = {};
// Mock Keychain
vi.mocked(KeychainTokenStorage).mockImplementation(
(serviceName: string) => {
if (!mockKeychainData[serviceName]) {
mockKeychainData[serviceName] = {};
}
const keychainData = mockKeychainData[serviceName];
return {
getSecret: vi
.fn()
.mockImplementation(
async (key: string) => keychainData[key] || null,
),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
} as unknown as KeychainTokenStorage;
},
);
// Setup Temp Dirs
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
const missing = await getMissingSettings(config, extensionId);
expect(missing).toEqual([]);
});
it('should identify missing non-sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s1');
});
it('should identify missing sensitive settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
],
};
const extensionId = '12345';
const missing = await getMissingSettings(config, extensionId);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe('s2');
});
it('should respect settings present in workspace', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const extensionId = '12345';
// Setup Workspace Env
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
const missing = await getMissingSettings(config, extensionId);
expect(missing).toEqual([]);
});
});
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.1.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
type: 'local',
autoUpdate: true,
};
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: { telemetry: {} } as any,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
});
// Mock methods called by installOrUpdateExtension
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
contextFiles: [],
mcpServers: {},
hooks: undefined,
isActive: true,
id: 'test-id',
settings: [],
resolvedSettings: [],
skills: [],
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
// Mock copyExtension? It's imported.
// We can rely on ignoring the failure if we mock enough.
// Actually copyExtension is called. We need to mock it if it does real IO.
// But we can just let it fail or mock fs.cp if it uses it.
// Let's assume the other mocks cover the critical path to the warning.
// Warning happens BEFORE copyExtension?
// No, warning is after copyExtension usually.
// But in my code:
// const missingSettings = await getMissingSettings(...)
// if (missingSettings.length > 0) debugLogger.warn(...)
// ...
// copyExtension(...)
// Wait, let's check extension-manager.ts order.
// Line 303: getMissingSettings
// Line 317: if (installMetadata.type === 'local' ...) copyExtension
// So getMissingSettings is called BEFORE copyExtension.
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'Please run "gemini extensions settings test-ext <setting-name>"',
),
);
});
});
});
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
ApprovalMode,
PolicyDecision,
@@ -13,6 +13,21 @@ import {
import { createPolicyEngineConfig } from './policy.js';
import type { Settings } from './settings.js';
// Mock Storage to ensure tests are hermetic and don't read from user's home directory
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const Storage = actual.Storage;
// Monkey-patch static methods
Storage.getUserPoliciesDir = () => '/non-existent/user/policies';
Storage.getSystemPoliciesDir = () => '/non-existent/system/policies';
return {
...actual,
Storage,
};
});
describe('Policy Engine Integration Tests', () => {
describe('Policy configuration produces valid PolicyEngine config', () => {
it('should create a working PolicyEngine from basic settings', async () => {
@@ -219,6 +219,60 @@ describe('settings-validation', () => {
}
});
it('should validate mcpServers with type field for all transport types', () => {
const validSettings = {
mcpServers: {
'sse-server': {
url: 'https://example.com/sse',
type: 'sse',
headers: { 'X-API-Key': 'key' },
},
'http-server': {
url: 'https://example.com/mcp',
type: 'http',
},
'stdio-server': {
command: '/usr/bin/mcp-server',
type: 'stdio',
},
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should reject invalid type values in mcpServers', () => {
const invalidSettings = {
mcpServers: {
'bad-server': {
url: 'https://example.com/mcp',
type: 'invalid-type',
},
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
});
it('should validate mcpServers without type field', () => {
const validSettings = {
mcpServers: {
'stdio-server': {
command: '/usr/bin/mcp-server',
args: ['--port', '8080'],
},
'url-server': {
url: 'https://example.com/mcp',
},
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should validate complex nested customThemes configuration', () => {
const invalidSettings = {
ui: {
@@ -269,9 +323,7 @@ describe('settings-validation', () => {
expect(formatted).toContain('/path/to/settings.json');
expect(formatted).toContain('model.name');
expect(formatted).toContain('Expected: string, but received: object');
expect(formatted).toContain(
'Please fix the configuration and try again.',
);
expect(formatted).toContain('Please fix the configuration.');
expect(formatted).toContain(
'https://github.com/google-gemini/gemini-cli',
);
@@ -322,7 +322,7 @@ export function formatValidationError(
lines.push('');
}
lines.push('Please fix the configuration and try again.');
lines.push('Please fix the configuration.');
lines.push(
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
);
File diff suppressed because it is too large Load Diff
+61 -319
View File
@@ -10,7 +10,6 @@ import { homedir, platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
debugLogger,
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
@@ -30,14 +29,12 @@ import {
getSettingsSchema,
} from './settingsSchema.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import {
validateSettings,
formatValidationError,
} from './settings-validation.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -66,79 +63,6 @@ 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 = true;
const MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed',
allowMCPServers: 'mcp.allowed',
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.compressionThreshold',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enableMessageBusIntegration: 'tools.enableMessageBusIntegration',
enableHooks: 'tools.enableHooks',
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',
showCitations: 'ui.showCitations',
ideMode: 'ide.enabled',
includeDirectories: 'context.includeDirectories',
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
maxSessionTurns: 'model.maxSessionTurns',
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: SettingPaths.General.PreferredEditor,
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
shellInactivityTimeout: 'tools.shell.inactivityTimeout',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
toolCallCommand: 'tools.callCommand',
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
useExternalAuth: 'security.auth.useExternal',
useRipgrep: 'tools.useRipgrep',
vimMode: 'general.vimMode',
};
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -233,6 +157,7 @@ export interface SessionRetentionSettings {
export interface SettingsError {
message: string;
path: string;
severity: 'error' | 'warning';
}
export interface SettingsFile {
@@ -267,160 +192,22 @@ function setNestedProperty(
current[lastKey] = value;
}
export function needsMigration(settings: Record<string, unknown>): 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 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(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
return null;
}
const v2Settings: Record<string, unknown> = {};
const flatKeys = new Set(Object.keys(flatSettings));
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
// If the key exists and is a V2 container (like 'model'), and the value is an object,
// it is likely already migrated or partially migrated. We should not move it
// to the mapped V2 path (e.g. 'model' -> 'model.name').
// Instead, let it fall through to the "Carry over" section to be merged.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
continue;
export function getDefaultsFromSchema(
schema: SettingsSchema = getSettingsSchema(),
): Settings {
const defaults: Record<string, unknown> = {};
for (const key in schema) {
const definition = schema[key];
if (definition.properties) {
const childDefaults = getDefaultsFromSchema(definition.properties);
if (Object.keys(childDefaults).length > 0) {
defaults[key] = childDefaults;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
} else if (definition.default !== undefined) {
defaults[key] = definition.default;
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
flatKeys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of flatKeys) {
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,
{},
existingValue as MergeableObject,
newValue as MergeableObject,
);
} else {
v2Settings[remainingKey] = newValue;
}
}
return v2Settings;
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
);
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {
const v1Settings: Record<string, unknown> = {};
const v2Keys = new Set(Object.keys(v2Settings));
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
const value = getNestedProperty(v2Settings, newPath);
if (value !== undefined) {
v1Settings[oldKey] = value;
v2Keys.delete(newPath.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
v2Keys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of v2Keys) {
const value = v2Settings[remainingKey];
if (value === undefined) {
continue;
}
// Don't carry over empty objects that were just containers for migrated settings.
if (
KNOWN_V2_CONTAINERS.has(remainingKey) &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
continue;
}
v1Settings[remainingKey] = value;
}
return v1Settings;
return defaults as Settings;
}
function mergeSettings(
@@ -431,16 +218,18 @@ function mergeSettings(
isTrusted: boolean,
): Settings {
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
const schemaDefaults = getDefaultsFromSchema();
// 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)
// 1. Schema Defaults (Built-in)
// 2. System Defaults
// 3. User Settings
// 4. Workspace Settings
// 5. System Settings (as overrides)
return customDeepMerge(
getMergeStrategyForPath,
{}, // Start with an empty object
schemaDefaults,
systemDefaults,
user,
safeWorkspace,
@@ -455,14 +244,14 @@ export class LoadedSettings {
user: SettingsFile,
workspace: SettingsFile,
isTrusted: boolean,
migratedInMemoryScopes: Set<SettingScope>,
errors: SettingsError[] = [],
) {
this.system = system;
this.systemDefaults = systemDefaults;
this.user = user;
this.workspace = workspace;
this.isTrusted = isTrusted;
this.migratedInMemoryScopes = migratedInMemoryScopes;
this.errors = errors;
this._merged = this.computeMergedSettings();
}
@@ -471,7 +260,7 @@ export class LoadedSettings {
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
readonly migratedInMemoryScopes: Set<SettingScope>;
readonly errors: SettingsError[];
private _merged: Settings;
@@ -510,6 +299,7 @@ export class LoadedSettings {
setNestedProperty(settingsFile.originalSettings, key, value);
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
coreEvents.emitSettingsChanged();
}
}
@@ -620,7 +410,6 @@ export function loadSettings(
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const migratedInMemoryScopes = new Set<SettingScope>();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
@@ -641,10 +430,7 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
): { settings: Settings; rawJson?: string } => {
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -658,77 +444,49 @@ export function loadSettings(
settingsErrors.push({
message: 'Settings file is not a valid JSON object.',
path: filePath,
severity: 'error',
});
return { settings: {} };
}
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
fs.renameSync(filePath, `${filePath}.orig`);
fs.writeFileSync(
filePath,
JSON.stringify(migratedSettings, null, 2),
'utf-8',
);
} catch (e) {
coreEvents.emitFeedback(
'error',
'Failed to migrate settings file.',
e,
);
}
} else {
migratedInMemoryScopes.add(scope);
}
settingsObject = migratedSettings;
}
}
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod after migration
// Validate settings structure with Zod
const validationResult = validateSettings(settingsObject);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
validationResult.error,
filePath,
);
throw new FatalConfigError(errorMessage);
settingsErrors.push({
message: errorMessage,
path: filePath,
severity: 'warning',
});
}
return { settings: settingsObject as Settings, rawJson: content };
}
} catch (error: unknown) {
// Preserve FatalConfigError with formatted validation messages
if (error instanceof FatalConfigError) {
throw error;
}
settingsErrors.push({
message: getErrorMessage(error),
path: filePath,
severity: 'error',
});
}
return { settings: {} };
};
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
const systemDefaultsResult = loadAndMigrate(
systemDefaultsPath,
SettingScope.SystemDefaults,
);
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
const systemResult = load(systemSettingsPath);
const systemDefaultsResult = load(systemDefaultsPath);
const userResult = load(USER_SETTINGS_PATH);
let workspaceResult: { settings: Settings; rawJson?: string } = {
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
workspaceResult = loadAndMigrate(
workspaceSettingsPath,
SettingScope.Workspace,
);
workspaceResult = load(workspaceSettingsPath);
}
const systemOriginalSettings = structuredClone(systemResult.settings);
@@ -779,10 +537,10 @@ export function loadSettings(
// the settings to avoid a cycle
loadEnvironment(tempMergedSettings);
// Create LoadedSettings first
if (settingsErrors.length > 0) {
const errorMessages = settingsErrors.map(
// Check for any fatal errors before proceeding
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
if (fatalErrors.length > 0) {
const errorMessages = fatalErrors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
@@ -816,36 +574,10 @@ export function loadSettings(
rawJson: workspaceResult.rawJson,
},
isTrusted,
migratedInMemoryScopes,
settingsErrors,
);
}
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
extensionManager: ExtensionManager,
): void {
const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
debugLogger.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
for (const extension of settings.extensions.disabled ?? []) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
extensionManager.disableExtension(extension, scope);
}
const newExtensionsValue = { ...settings.extensions };
newExtensionsValue.disabled = undefined;
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
@@ -854,12 +586,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
fs.mkdirSync(dirPath, { recursive: true });
}
let settingsToSave = settingsFile.originalSettings;
if (!MIGRATE_V2_OVERWRITE) {
settingsToSave = migrateSettingsToV1(
settingsToSave as Record<string, unknown>,
) as Settings;
}
const settingsToSave = settingsFile.originalSettings;
// Use the format-preserving update function
updateSettingsFilePreservingFormat(
@@ -874,3 +601,18 @@ export function saveSettings(settingsFile: SettingsFile): void {
);
}
}
export function saveModelChange(
loadedSettings: LoadedSettings,
model: string,
): void {
try {
loadedSettings.setValue(SettingScope.User, 'model.name', model);
} catch (error) {
coreEvents.emitFeedback(
'error',
'There was an error saving your preferred model.',
error,
);
}
}
@@ -357,6 +357,15 @@ describe('SettingsSchema', () => {
);
});
it('should have hooks.notifications setting in schema', () => {
const setting = getSettingsSchema().hooks.properties.notifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Advanced');
expect(setting.default).toBe(true);
expect(setting.showInDialog).toBe(true);
});
it('should have name and description in hook definitions', () => {
const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray'];
expect(hookDef).toBeDefined();
+198 -25
View File
@@ -953,6 +953,16 @@ const SETTINGS_SCHEMA = {
'The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.',
showInDialog: false,
},
enableShellOutputEfficiency: {
type: 'boolean',
label: 'Enable Shell Output Efficiency',
category: 'Tools',
requiresRestart: false,
default: true,
description:
'Enable shell output efficiency optimizations for better performance.',
showInDialog: false,
},
},
},
autoAccept: {
@@ -1063,26 +1073,14 @@ const SETTINGS_SCHEMA = {
description: 'The number of lines to keep when truncating tool output.',
showInDialog: true,
},
enableMessageBusIntegration: {
type: 'boolean',
label: 'Enable Message Bus Integration',
category: 'Tools',
requiresRestart: true,
default: true,
description: oneLine`
Enable policy-based tool confirmation via message bus integration.
When enabled, tools automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.
`,
showInDialog: true,
},
enableHooks: {
type: 'boolean',
label: 'Enable Hooks System',
label: 'Enable Hooks System (Experimental)',
category: 'Advanced',
requiresRestart: true,
default: false,
default: true,
description:
'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.',
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
showInDialog: false,
},
},
@@ -1128,15 +1126,6 @@ const SETTINGS_SCHEMA = {
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',
category: 'Advanced',
requiresRestart: false,
default: true,
description: 'Enable the smart-edit tool instead of the replace tool.',
showInDialog: false,
},
useWriteTodos: {
type: 'boolean',
label: 'Use WriteTodos',
@@ -1203,6 +1192,48 @@ const SETTINGS_SCHEMA = {
},
},
},
environmentVariableRedaction: {
type: 'object',
label: 'Environment Variable Redaction',
category: 'Security',
requiresRestart: false,
default: {},
description: 'Settings for environment variable redaction.',
showInDialog: false,
properties: {
allowed: {
type: 'array',
label: 'Allowed Environment Variables',
category: 'Security',
requiresRestart: true,
default: [] as string[],
description:
'Environment variables to always allow (bypass redaction).',
showInDialog: false,
items: { type: 'string' },
},
blocked: {
type: 'array',
label: 'Blocked Environment Variables',
category: 'Security',
requiresRestart: true,
default: [] as string[],
description: 'Environment variables to always redact.',
showInDialog: false,
items: { type: 'string' },
},
enabled: {
type: 'boolean',
label: 'Enable Environment Variable Redaction',
category: 'Security',
requiresRestart: true,
default: false,
description:
'Enable redaction of environment variables that may contain secrets.',
showInDialog: true,
},
},
},
auth: {
type: 'object',
label: 'Authentication',
@@ -1343,6 +1374,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: false,
},
skills: {
type: 'boolean',
label: 'Agent Skills',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable Agent Skills (experimental).',
showInDialog: true,
},
codebaseInvestigatorSettings: {
type: 'object',
label: 'Codebase Investigator Settings',
@@ -1403,6 +1443,16 @@ const SETTINGS_SCHEMA = {
},
},
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
showInDialog: true,
},
introspectionAgentSettings: {
type: 'object',
label: 'Introspection Agent Settings',
@@ -1461,6 +1511,29 @@ const SETTINGS_SCHEMA = {
},
},
skills: {
type: 'object',
label: 'Skills',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings for agent skills.',
showInDialog: false,
properties: {
disabled: {
type: 'array',
label: 'Disabled Skills',
category: 'Advanced',
requiresRestart: true,
default: [] as string[],
description: 'List of disabled skills.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
},
},
hooks: {
type: 'object',
label: 'Hooks',
@@ -1471,6 +1544,16 @@ const SETTINGS_SCHEMA = {
'Hook configurations for intercepting and customizing agent behavior.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
},
disabled: {
type: 'array',
label: 'Disabled Hooks',
@@ -1486,6 +1569,15 @@ const SETTINGS_SCHEMA = {
},
mergeStrategy: MergeStrategy.UNION,
},
notifications: {
type: 'boolean',
label: 'Hook Notifications',
category: 'Advanced',
requiresRestart: false,
default: true,
description: 'Show visual indicators when hooks are executing.',
showInDialog: true,
},
BeforeTool: {
type: 'array',
label: 'Before Tool Hooks',
@@ -1626,6 +1718,74 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
},
},
admin: {
type: 'object',
label: 'Admin',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'Settings configured remotely by enterprise admins.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
secureModeEnabled: {
type: 'boolean',
label: 'Secure Mode Enabled',
category: 'Admin',
requiresRestart: false,
default: false,
description: 'If true, disallows yolo mode from being used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
extensions: {
type: 'object',
label: 'Extensions Settings',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'Extensions-specific admin settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
enabled: {
type: 'boolean',
label: 'Extensions Enabled',
category: 'Admin',
requiresRestart: false,
default: true,
description:
'If false, disallows extensions from being installed or used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
},
},
mcp: {
type: 'object',
label: 'MCP Settings',
category: 'Admin',
requiresRestart: false,
default: {},
description: 'MCP-specific admin settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
properties: {
enabled: {
type: 'boolean',
label: 'MCP Enabled',
category: 'Admin',
requiresRestart: false,
default: true,
description: 'If false, disallows MCP servers from being used.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
},
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
@@ -1662,7 +1822,8 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
url: {
type: 'string',
description: 'SSE transport URL.',
description:
'URL for SSE or HTTP transport. Use with "type" field to specify transport type.',
},
httpUrl: {
type: 'string',
@@ -1677,6 +1838,12 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'string',
description: 'TCP address for websocket transport.',
},
type: {
type: 'string',
description:
'Transport type. Use "stdio" for local command, "sse" for Server-Sent Events, or "http" for Streamable HTTP.',
enum: ['stdio', 'sse', 'http'],
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for MCP requests.',
@@ -1968,3 +2135,9 @@ type InferSettings<T extends SettingsSchema> = {
};
export type Settings = InferSettings<SettingsSchemaType>;
export function getEnableHooks(settings: Settings): boolean {
return (
(settings.tools?.enableHooks ?? true) && (settings.hooks?.enabled ?? false)
);
}
@@ -150,7 +150,7 @@ describe('Settings Repro', () => {
showColor: true,
enableInteractiveShell: true,
},
enableMessageBusIntegration: true,
truncateToolOutputLines: 100,
},
experimental: {
useModelRouter: false,
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import * as fs from 'node:fs';
const mockCoreEvents = vi.hoisted(() => ({
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
emitOutput: vi.fn(),
emitModelChanged: vi.fn(),
drainBacklogs: vi.fn(),
}));
const mockIsWorkspaceTrusted = vi.hoisted(() =>
vi.fn().mockReturnValue({ isTrusted: true, source: 'file' }),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: mockCoreEvents,
Storage: class extends actual.Storage {
static override getGlobalSettingsPath = () =>
'/mock/home/user/.gemini/settings.json';
override getWorkspaceSettingsPath = () =>
'/mock/workspace/.gemini/settings.json';
static override getGlobalGeminiDir = () => '/mock/home/user/.gemini';
},
};
});
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: mockIsWorkspaceTrusted,
loadTrustedFolders: vi.fn().mockReturnValue({
isPathTrusted: vi.fn().mockReturnValue(true),
user: { config: {} },
errors: [],
}),
isFolderTrustEnabled: vi.fn().mockReturnValue(false),
TrustLevel: {
TRUST_FOLDER: 'TRUST_FOLDER',
TRUST_PARENT: 'TRUST_PARENT',
DO_NOT_TRUST: 'DO_NOT_TRUST',
},
}));
vi.mock('os', () => ({
homedir: () => '/mock/home/user',
platform: () => 'linux',
totalmem: () => 16 * 1024 * 1024 * 1024,
}));
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
realpathSync: (p: string) => p,
};
});
// Import loadSettings after all mocks are defined
import {
loadSettings,
USER_SETTINGS_PATH,
type LoadedSettings,
} from './settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
describe('Settings Validation Warning', () => {
beforeEach(() => {
vi.clearAllMocks();
(fs.readFileSync as Mock).mockReturnValue('{}');
(fs.existsSync as Mock).mockReturnValue(false);
});
it('should emit a warning and NOT throw when settings are invalid', () => {
(fs.existsSync as Mock).mockImplementation(
(p: string) => p === USER_SETTINGS_PATH,
);
const invalidSettingsContent = {
ui: {
customThemes: {
terafox: {
name: 'terafox',
type: 'custom',
DiffModified: '#ffffff', // Invalid key
},
},
},
};
(fs.readFileSync as Mock).mockImplementation((p: string) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(invalidSettingsContent);
return '{}';
});
// Should NOT throw
let settings: LoadedSettings | undefined;
expect(() => {
settings = loadSettings(MOCK_WORKSPACE_DIR);
}).not.toThrow();
// Should have recorded a warning in the settings object
expect(
settings?.errors.some((e) =>
e.message.includes("Unrecognized key(s) in object: 'DiffModified'"),
),
).toBe(true);
});
it('should throw a fatal error when settings file is not a valid JSON object', () => {
(fs.existsSync as Mock).mockImplementation(
(p: string) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation((p: string) => {
if (p === USER_SETTINGS_PATH) return '[]';
return '{}';
});
expect(() => {
loadSettings(MOCK_WORKSPACE_DIR);
}).toThrow();
});
it('should throw a fatal error when settings file contains invalid JSON', () => {
(fs.existsSync as Mock).mockImplementation(
(p: string) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation((p: string) => {
if (p === USER_SETTINGS_PATH) return '{ "invalid": "json", }'; // Trailing comma is invalid in standard JSON
return '{}';
});
expect(() => {
loadSettings(MOCK_WORKSPACE_DIR);
}).toThrow();
});
});
@@ -161,7 +161,6 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath();
// Load user trusted folders
try {
if (fs.existsSync(userPath)) {
+20 -6
View File
@@ -29,6 +29,7 @@ import {
type Config,
type ResumedSessionData,
debugLogger,
coreEvents,
} from '@google/gemini-cli-core';
import { act } from 'react';
import { type InitializationResult } from './core/initializer.js';
@@ -114,6 +115,7 @@ vi.mock('./config/settings.js', () => ({
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -279,6 +281,7 @@ describe('gemini.tsx main function', () => {
getOutputFormat: () => 'text',
getExtensions: () => [],
getUsageStatisticsEnabled: () => false,
refreshAuth: vi.fn(),
setTerminalBackground: vi.fn(),
} as unknown as Config;
});
@@ -289,6 +292,7 @@ describe('gemini.tsx main function', () => {
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
@@ -522,6 +526,7 @@ describe('gemini.tsx main function kitty protocol', () => {
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
@@ -541,7 +546,6 @@ describe('gemini.tsx main function kitty protocol', () => {
listExtensions: undefined,
includeDirectories: undefined,
screenReader: undefined,
useSmartEdit: undefined,
useWriteTodos: undefined,
resume: undefined,
listSessions: undefined,
@@ -583,6 +587,7 @@ describe('gemini.tsx main function kitty protocol', () => {
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -669,6 +674,7 @@ describe('gemini.tsx main function kitty protocol', () => {
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -737,6 +743,7 @@ describe('gemini.tsx main function kitty protocol', () => {
security: { auth: {} },
ui: { theme: 'non-existent-theme' },
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -776,6 +783,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
refreshAuth: vi.fn(),
setTerminalBackground: vi.fn(),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -813,12 +821,11 @@ describe('gemini.tsx main function kitty protocol', () => {
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -868,12 +875,13 @@ describe('gemini.tsx main function kitty protocol', () => {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'error',
expect.stringContaining('Error resuming session: Session not found'),
);
expect(processExitSpy).toHaveBeenCalledWith(42);
processExitSpy.mockRestore();
consoleErrorSpy.mockRestore();
emitFeedbackSpy.mockRestore();
});
it.skip('should log error when cleanupExpiredSessions fails', async () => {
@@ -898,6 +906,7 @@ describe('gemini.tsx main function kitty protocol', () => {
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -971,6 +980,7 @@ describe('gemini.tsx main function kitty protocol', () => {
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -1010,6 +1020,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
refreshAuth: vi.fn(),
setTerminalBackground: vi.fn(),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -1118,6 +1129,7 @@ describe('gemini.tsx main function exit codes', () => {
security: { auth: { selectedType: 'google', useExternal: false } },
ui: {},
},
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
@@ -1174,6 +1186,7 @@ describe('gemini.tsx main function exit codes', () => {
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(parseArguments).mockResolvedValue({
@@ -1237,6 +1250,7 @@ describe('gemini.tsx main function exit codes', () => {
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
+93 -49
View File
@@ -17,10 +17,10 @@ import dns from 'node:dns';
import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
loadTrustedFolders,
type TrustedFoldersError,
} from './config/trustedFolders.js';
import { loadSettings, SettingScope } from './config/settings.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
@@ -37,6 +37,7 @@ import {
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
sessionId,
logUserPrompt,
AuthType,
@@ -90,9 +91,7 @@ import {
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
@@ -299,19 +298,19 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
const migrateHandle = startupProfiler.start('migrate_settings');
migrateDeprecatedSettings(
settings,
// Temporary extension manager only used during this non-interactive UI phase.
new ExtensionManager({
workspaceDir: process.cwd(),
settings: settings.merged,
enabledExtensionOverrides: [],
requestConsent: requestConsentNonInteractive,
requestSetting: null,
}),
);
migrateHandle?.end();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
});
const trustedFolders = loadTrustedFolders();
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
coreEvents.emitFeedback(
'warning',
`Error in ${error.path}: ${error.message}`,
);
});
await cleanupCheckpoints();
const parseArgsHandle = startupProfiler.start('parse_arguments');
@@ -376,24 +375,35 @@ export async function main() {
settings.merged,
sessionId,
argv,
{ projectHooks: settings.workspace.settings.hooks },
);
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
throw new Error(err);
}
if (partialConfig.isInteractive()) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
const err = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
throw new Error(err);
}
await partialConfig.refreshAuth(
settings.merged.security.auth.selectedType,
);
await partialConfig.refreshAuth(
settings.merged.security.auth.selectedType,
);
} else {
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
partialConfig,
settings,
);
await partialConfig.refreshAuth(authType);
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
await runExitCleanup();
@@ -447,7 +457,9 @@ export async function main() {
// may have side effects.
{
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv);
const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
});
loadConfigHandle?.end();
// Register config for telemetry shutdown
@@ -577,7 +589,8 @@ export async function main() {
// Use the existing session ID to continue recording to the same session
config.setSessionId(resumedSessionData.conversation.sessionId);
} catch (error) {
console.error(
coreEvents.emitFeedback(
'error',
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
@@ -602,6 +615,16 @@ export async function main() {
await config.initialize();
startupProfiler.flush(config);
// If not a TTY, read from stdin
// This is for cases where the user pipes input directly into the command
let stdinData: string | undefined = undefined;
if (!process.stdin.isTTY) {
stdinData = await readStdin();
if (stdinData) {
input = input ? `${stdinData}\n\n${input}` : stdinData;
}
}
// Fire SessionStart hook through MessageBus (only if hooks are enabled)
// Must be called AFTER config.initialize() to ensure HookRegistry is loaded
const hooksEnabled = config.getEnableHooks();
@@ -610,7 +633,23 @@ export async function main() {
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
await fireSessionStartHook(hookMessageBus, sessionStartSource);
const result = await fireSessionStartHook(
hookMessageBus,
sessionStartSource,
);
if (result) {
if (result.systemMessage) {
writeToStderr(result.systemMessage + '\n');
}
const additionalContext = result.getAdditionalContext();
if (additionalContext) {
// Prepend context to input (System Context -> Stdin -> Question)
input = input
? `${additionalContext}\n\n${input}`
: additionalContext;
}
}
// Register SessionEnd hook for graceful exit
registerCleanup(async () => {
@@ -618,14 +657,6 @@ export async function main() {
});
}
// 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) {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
@@ -645,12 +676,13 @@ export async function main() {
),
);
const nonInteractiveConfig = await validateNonInteractiveAuth(
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
);
await config.refreshAuth(authType);
if (config.getDebugMode()) {
debugLogger.log('Session ID: %s', sessionId);
@@ -662,7 +694,7 @@ export async function main() {
initializeOutputListenersAndFlush();
await runNonInteractive({
config: nonInteractiveConfig,
config,
settings,
input,
prompt_id,
@@ -699,13 +731,25 @@ export function initializeOutputListenersAndFlush() {
}
});
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
if (payload.type === 'error' || payload.type === 'warn') {
writeToStderr(payload.content);
} else {
writeToStdout(payload.content);
}
});
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
if (payload.type === 'error' || payload.type === 'warn') {
writeToStderr(payload.content);
} else {
writeToStdout(payload.content);
}
});
}
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
if (payload.severity === 'error' || payload.severity === 'warning') {
writeToStderr(payload.message);
} else {
writeToStdout(payload.message);
}
});
}
}
coreEvents.drainBacklogs();
}
+3
View File
@@ -60,6 +60,7 @@ vi.mock('./config/settings.js', async (importOriginal) => {
...actual,
loadSettings: vi.fn().mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -171,6 +172,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
@@ -211,6 +213,7 @@ describe('gemini.tsx main function cleanup', () => {
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => false),
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
+116 -12
View File
@@ -44,6 +44,7 @@ const mockCoreEvents = vi.hoisted(() => ({
off: vi.fn(),
drainBacklogs: vi.fn(),
emit: vi.fn(),
emitFeedback: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -785,11 +786,6 @@ describe('runNonInteractive', () => {
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({
@@ -807,7 +803,8 @@ describe('runNonInteractive', () => {
// Should throw because of mocked process.exit
expect(thrownError?.message).toBe('process.exit(1) called');
expect(consoleErrorJsonSpy).toHaveBeenCalledWith(
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
JSON.stringify(
{
session_id: 'test-session-id',
@@ -831,11 +828,6 @@ describe('runNonInteractive', () => {
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({
@@ -853,7 +845,8 @@ describe('runNonInteractive', () => {
// Should throw because of mocked process.exit with custom exit code
expect(thrownError?.message).toBe('process.exit(42) called');
expect(consoleErrorJsonSpy).toHaveBeenCalledWith(
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
JSON.stringify(
{
session_id: 'test-session-id',
@@ -1752,4 +1745,115 @@ describe('runNonInteractive', () => {
);
expect(getWrittenOutput()).toContain('Done');
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'stop-call',
name: 'stopTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-stop',
},
};
// Mock tool execution returning STOP_EXECUTION
mockCoreExecuteToolCall.mockResolvedValue({
status: 'error',
request: toolCallEvent.value,
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
callId: 'stop-call',
responseParts: [{ text: 'error occurred' }],
errorType: ToolErrorType.STOP_EXECUTION,
error: new Error('Stop reason from hook'),
resultDisplay: undefined,
},
});
const firstCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Executing tool...' },
toolCallEvent,
];
// Setup the mock to return events for the first call.
// We expect the loop to terminate after the tool execution.
// If it doesn't, it might call sendMessageStream again, which we'll assert against.
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents([]));
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Run stop tool',
prompt_id: 'prompt-id-stop',
});
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
describe('Agent Execution Events', () => {
it('should handle AgentExecutionStopped event', async () => {
const events: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionStopped,
value: { reason: 'Stopped by hook' },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test stop',
prompt_id: 'prompt-id-stop',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'Agent execution stopped: Stopped by hook\n',
);
// Should exit without calling sendMessageStream again
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle AgentExecutionBlocked event', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Blocked by hook' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(allEvents),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test block',
prompt_id: 'prompt-id-block',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[WARNING] Agent execution blocked: Blocked by hook\n',
);
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
expect(getWrittenOutput()).toBe('Final answer\n');
});
});
});
+63
View File
@@ -28,6 +28,7 @@ import {
CoreEvent,
createWorkingStdio,
recordToolCallInteractions,
ToolErrorType,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
@@ -347,6 +348,31 @@ export async function runNonInteractive({
}
} else if (event.type === GeminiEventType.Error) {
throw event.value.error;
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
const stopMessage = `Agent execution stopped: ${event.value.reason}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`${stopMessage}\n`);
}
// Emit final result event for streaming JSON if needed
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(
metrics,
durationMs,
),
});
}
return;
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
const blockMessage = `Agent execution blocked: ${event.value.reason}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[WARNING] ${blockMessage}\n`);
}
}
}
@@ -416,6 +442,43 @@ export async function runNonInteractive({
);
}
// Check if any tool requested to stop execution immediately
const stopExecutionTool = completedToolCalls.find(
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
);
if (stopExecutionTool && stopExecutionTool.response.error) {
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`${stopMessage}\n`);
}
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(
metrics,
durationMs,
),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// Emit final result event for streaming JSON
@@ -79,6 +79,9 @@ vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} }));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
vi.mock('../ui/commands/skillsCommand.js', () => ({
skillsCommand: { name: 'skills' },
}));
vi.mock('../ui/commands/mcpCommand.js', () => ({
mcpCommand: {
name: 'mcp',
@@ -96,9 +99,13 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getEnableMessageBusIntegration: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@@ -172,26 +179,14 @@ describe('BuiltinCommandLoader', () => {
it('should include policies command when message bus integration is enabled', async () => {
const mockConfigWithMessageBus = {
...mockConfig,
getEnableMessageBusIntegration: () => true,
getEnableHooks: () => false,
getMcpEnabled: () => true,
} as unknown as Config;
const loader = new BuiltinCommandLoader(mockConfigWithMessageBus);
const commands = await loader.loadCommands(new AbortController().signal);
const policiesCmd = commands.find((c) => c.name === 'policies');
expect(policiesCmd).toBeDefined();
});
it('should exclude policies command when message bus integration is disabled', async () => {
const mockConfigWithoutMessageBus = {
...mockConfig,
getEnableMessageBusIntegration: () => false,
getEnableHooks: () => false,
} as unknown as Config;
const loader = new BuiltinCommandLoader(mockConfigWithoutMessageBus);
const commands = await loader.loadCommands(new AbortController().signal);
const policiesCmd = commands.find((c) => c.name === 'policies');
expect(policiesCmd).toBeUndefined();
});
});
describe('BuiltinCommandLoader profile', () => {
@@ -202,9 +197,13 @@ describe('BuiltinCommandLoader profile', () => {
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: () => false,
getEnableMessageBusIntegration: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
getMcpEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
});
@@ -6,8 +6,12 @@
import { isDevelopment } from '../utils/installationInfo.js';
import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import {
CommandKind,
type SlashCommand,
type CommandContext,
} from '../ui/commands/types.js';
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
import { startupProfiler } from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
@@ -38,6 +42,7 @@ import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
@@ -76,14 +81,30 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
mcpCommand,
...(this.config?.getMcpEnabled() === false
? [
{
name: 'mcp',
description:
'Manage configured Model Context Protocol (MCP) servers',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [],
action: async (
_context: CommandContext,
): Promise<MessageActionReturn> => ({
type: 'message',
messageType: 'error',
content: 'MCP disabled by your admin.',
}),
},
]
: [mcpCommand]),
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
privacyCommand,
...(this.config?.getEnableMessageBusIntegration()
? [policiesCommand]
: []),
policiesCommand,
...(isDevelopment ? [profileCommand] : []),
quitCommand,
restoreCommand(this.config),
@@ -91,6 +112,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
statsCommand,
themeCommand,
toolsCommand,
...(this.config?.isSkillsSupportEnabled() ? [skillsCommand] : []),
settingsCommand,
vimCommand,
setupGithubCommand,
+11 -6
View File
@@ -10,7 +10,7 @@ import toml from '@iarna/toml';
import { glob } from 'glob';
import { z } from 'zod';
import type { Config } from '@google/gemini-cli-core';
import { Storage } from '@google/gemini-cli-core';
import { Storage, coreEvents } from '@google/gemini-cli-core';
import type { ICommandLoader } from './types.js';
import type {
CommandContext,
@@ -126,7 +126,8 @@ export class FileCommandLoader implements ICommandLoader {
!signal.aborted &&
(error as { code?: string })?.code !== 'ENOENT'
) {
console.error(
coreEvents.emitFeedback(
'error',
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,
);
@@ -189,7 +190,8 @@ export class FileCommandLoader implements ICommandLoader {
try {
fileContent = await fs.readFile(filePath, 'utf-8');
} catch (error: unknown) {
console.error(
coreEvents.emitFeedback(
'error',
`[FileCommandLoader] Failed to read file ${filePath}:`,
error instanceof Error ? error.message : String(error),
);
@@ -200,7 +202,8 @@ export class FileCommandLoader implements ICommandLoader {
try {
parsed = toml.parse(fileContent);
} catch (error: unknown) {
console.error(
coreEvents.emitFeedback(
'error',
`[FileCommandLoader] Failed to parse TOML file ${filePath}:`,
error instanceof Error ? error.message : String(error),
);
@@ -210,7 +213,8 @@ export class FileCommandLoader implements ICommandLoader {
const validationResult = TomlCommandDefSchema.safeParse(parsed);
if (!validationResult.success) {
console.error(
coreEvents.emitFeedback(
'error',
`[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,
validationResult.error.flatten(),
);
@@ -278,7 +282,8 @@ export class FileCommandLoader implements ICommandLoader {
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.invocation) {
console.error(
coreEvents.emitFeedback(
'error',
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
);
return {
+2 -2
View File
@@ -109,7 +109,7 @@ export const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
export const createMockSettings = (
@@ -122,7 +122,7 @@ export const createMockSettings = (
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
};
+2 -6
View File
@@ -14,11 +14,7 @@ import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { AppContext, type AppState } from './contexts/AppContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
type SettingScope,
LoadedSettings,
type SettingsFile,
} from '../config/settings.js';
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -92,7 +88,7 @@ describe('App', () => {
mockSettingsFile,
mockSettingsFile,
true,
new Set<SettingScope>(),
[],
);
const mockAppState: AppState = {
+19
View File
@@ -143,6 +143,7 @@ vi.mock('./contexts/SessionContext.js');
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/useHookDisplayState.js');
// Mock external utilities
vi.mock('../utils/events.js');
@@ -171,6 +172,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -243,6 +245,7 @@ describe('AppContainer State Management', () => {
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
beforeEach(() => {
vi.clearAllMocks();
@@ -363,6 +366,7 @@ describe('AppContainer State Management', () => {
elapsedTime: '0.0s',
currentLoadingPhrase: '',
});
mockedUseHookDisplayState.mockReturnValue([]);
// Mock Config
mockConfig = makeFakeConfig();
@@ -1874,6 +1878,21 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.currentModel).toBe('new-model');
unmount!();
});
it('provides activeHooks from useHookDisplayState', async () => {
const mockHooks = [{ name: 'hook1', eventName: 'event1' }];
mockedUseHookDisplayState.mockReturnValue(mockHooks);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.activeHooks).toEqual(mockHooks);
unmount!();
});
});
describe('Shell Interaction', () => {
+58 -23
View File
@@ -91,7 +91,6 @@ import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -123,11 +122,12 @@ import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { enableBracketedPaste } from './utils/bracketedPaste.js';
import { useBanner } from './hooks/useBanner.js';
const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
} from './constants.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -190,6 +190,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [historyRemountKey, setHistoryRemountKey] = useState(0);
const [settingsNonce, setSettingsNonce] = useState(0);
const activeHooks = useHookDisplayState();
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
isWorkspaceTrusted(settings.merged).isTrusted,
@@ -253,10 +255,7 @@ export const AppContainer = (props: AppContainerProps) => {
[],
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => config.getModel(), [config]);
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
const [currentModel, setCurrentModel] = useState(config.getModel());
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
@@ -303,7 +302,31 @@ export const AppContainer = (props: AppContainerProps) => {
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
await fireSessionStartHook(hookMessageBus, sessionStartSource);
const result = await fireSessionStartHook(
hookMessageBus,
sessionStartSource,
);
if (result) {
if (result.systemMessage) {
historyManager.addItem(
{
type: MessageType.INFO,
text: result.systemMessage,
},
Date.now(),
);
}
const additionalContext = result.getAdditionalContext();
const geminiClient = config.getGeminiClient();
if (additionalContext && geminiClient) {
await geminiClient.addHistory({
role: 'user',
parts: [{ text: additionalContext }],
});
}
}
}
// Fire-and-forget: generate summary for previous session in background
@@ -324,6 +347,12 @@ export const AppContainer = (props: AppContainerProps) => {
await fireSessionEndHook(hookMessageBus, SessionEndReason.Exit);
}
});
// Disable the dependencies check here. historyManager gets flagged
// but we don't want to react to changes to it because each new history
// item, including the ones from the start session hook will cause a
// re-render and an error when we try to reload config.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, resumedSessionData]);
useEffect(
@@ -341,7 +370,18 @@ export const AppContainer = (props: AppContainerProps) => {
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
};
}, [getEffectiveModel, config]);
}, [config]);
useEffect(() => {
const handleSettingsChanged = () => {
setSettingsNonce((prev) => prev + 1);
};
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
return () => {
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
};
}, []);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -397,8 +437,7 @@ export const AppContainer = (props: AppContainerProps) => {
disableLineWrapping();
app.rerender();
}
enableBracketedPaste();
terminalCapabilityManager.enableKittyProtocol();
terminalCapabilityManager.enableSupportedModes();
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]);
@@ -845,16 +884,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleClearScreen = useCallback(() => {
historyManager.clearItems();
clearConsoleMessagesState();
if (!isAlternateBuffer) {
console.clear();
}
refreshStatic();
}, [
historyManager,
clearConsoleMessagesState,
refreshStatic,
isAlternateBuffer,
]);
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -902,10 +933,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
),
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
sanitizationConfig: config.sanitizationConfig,
});
const isFocused = useFocus();
useBracketedPaste();
// Context file names computation
const contextFileNames = useMemo(() => {
@@ -1494,6 +1525,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
elapsedTime,
currentLoadingPhrase,
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
@@ -1530,6 +1562,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
}),
[
isThemeDialogOpen,
@@ -1583,6 +1616,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
elapsedTime,
currentLoadingPhrase,
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
@@ -1622,6 +1656,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerData,
bannerVisible,
config,
settingsNonce,
],
);
@@ -4,14 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render } from '../test-utils/render.js';
import { act } from 'react';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { debugLogger } from '@google/gemini-cli-core';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock debugLogger
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
describe('IdeIntegrationNudge', () => {
const defaultProps = {
ide: {
@@ -21,24 +37,20 @@ describe('IdeIntegrationNudge', () => {
onComplete: vi.fn(),
};
const originalError = console.error;
afterEach(() => {
console.error = originalError;
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
beforeEach(() => {
console.error = (...args) => {
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
if (
typeof args[0] === 'string' &&
/was not wrapped in act/.test(args[0])
) {
return;
}
originalError.call(console, ...args);
};
});
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '');
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
});
@@ -5,12 +5,27 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import { AuthInProgress } from './AuthInProgress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
@@ -22,24 +37,20 @@ vi.mock('../components/CliSpinner.js', () => ({
describe('AuthInProgress', () => {
const onTimeout = vi.fn();
const originalError = console.error;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
console.error = (...args) => {
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
return;
}
originalError.call(console, ...args);
};
});
});
afterEach(() => {
console.error = originalError;
vi.useRealTimers();
});
+14 -1
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { DefaultHookOutput } from '@google/gemini-cli-core';
import {
uiTelemetryService,
fireSessionEndHook,
@@ -14,6 +15,7 @@ import {
} from '@google/gemini-cli-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { randomUUID } from 'node:crypto';
export const clearCommand: SlashCommand = {
@@ -52,8 +54,9 @@ export const clearCommand: SlashCommand = {
}
// Fire SessionStart hook after clearing
let result: DefaultHookOutput | undefined;
if (config?.getEnableHooks() && messageBus) {
await fireSessionStartHook(messageBus, SessionStartSource.Clear);
result = await fireSessionStartHook(messageBus, SessionStartSource.Clear);
}
// Give the event loop a chance to process any pending telemetry operations
@@ -68,5 +71,15 @@ export const clearCommand: SlashCommand = {
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
if (result?.systemMessage) {
context.ui.addItem(
{
type: MessageType.INFO,
text: result.systemMessage,
},
Date.now(),
);
}
},
};
@@ -4,10 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import { directoryCommand } from './directoryCommand.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import {
expandHomeDir,
getDirectorySuggestions,
} from '../utils/directoryUtils.js';
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
@@ -17,6 +20,15 @@ import * as path from 'node:path';
import * as trustedFolders from '../../config/trustedFolders.js';
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/directoryUtils.js')>();
return {
...actual,
getDirectorySuggestions: vi.fn(),
};
});
describe('directoryCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
@@ -217,6 +229,47 @@ describe('directoryCommand', () => {
expect.any(Number),
);
});
describe('completion', () => {
const completion = addCommand!.completion!;
it('should return empty suggestions for an empty path', async () => {
const results = await completion(mockContext, '');
expect(results).toEqual([]);
});
it('should return empty suggestions for whitespace only path', async () => {
const results = await completion(mockContext, ' ');
expect(results).toEqual([]);
});
it('should return suggestions for a single path', async () => {
vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']);
const results = await completion(mockContext, 'd');
expect(getDirectorySuggestions).toHaveBeenCalledWith('d');
expect(results).toEqual(['docs/', 'src/']);
});
it('should return suggestions for multiple paths', async () => {
vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
const results = await completion(mockContext, 'docs/,s');
expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
expect(results).toEqual(['docs/,src/']);
});
it('should handle leading whitespace in suggestions', async () => {
vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
const results = await completion(mockContext, 'docs/, s');
expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
expect(results).toEqual(['docs/, src/']);
});
});
});
describe('add with folder trust enabled', () => {
@@ -14,7 +14,10 @@ import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItem } from '../types.js';
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import { expandHomeDir } from '../utils/directoryUtils.js';
import {
expandHomeDir,
getDirectorySuggestions,
} from '../utils/directoryUtils.js';
import type { Config } from '@google/gemini-cli-core';
async function finishAddingDirectories(
@@ -80,6 +83,27 @@ export const directoryCommand: SlashCommand = {
'Add directories to the workspace. Use comma to separate multiple paths',
kind: CommandKind.BUILT_IN,
autoExecute: false,
showCompletionLoading: false,
completion: async (context: CommandContext, partialArg: string) => {
// Support multiple paths separated by commas
const parts = partialArg.split(',');
const lastPart = parts[parts.length - 1];
const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? '';
const trimmedLastPart = lastPart.trimStart();
if (trimmedLastPart === '') {
return [];
}
const suggestions = await getDirectorySuggestions(trimmedLastPart);
if (parts.length > 1) {
const prefix = parts.slice(0, -1).join(',') + ',';
return suggestions.map((s) => prefix + leadingWhitespace + s);
}
return suggestions.map((s) => leadingWhitespace + s);
},
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
@@ -26,9 +26,21 @@ import {
type MockedFunction,
} from 'vitest';
import { type ExtensionUpdateAction } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
inferInstallMetadata: vi.fn(),
};
});
import open from 'open';
vi.mock('open', () => ({
@@ -42,6 +54,8 @@ vi.mock('../../config/extensions/update.js', () => ({
const mockDisableExtension = vi.fn();
const mockEnableExtension = vi.fn();
const mockInstallExtension = vi.fn();
const mockUninstallExtension = vi.fn();
const mockGetExtensions = vi.fn();
const inactiveExt: GeminiCLIExtension = {
@@ -102,6 +116,8 @@ describe('extensionsCommand', () => {
Object.assign(actual, {
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
installOrUpdateExtension: mockInstallExtension,
uninstallExtension: mockUninstallExtension,
getExtensions: mockGetExtensions,
});
return actual;
@@ -477,29 +493,189 @@ describe('extensionsCommand', () => {
});
describe('when enableExtensionReloading is true', () => {
it('should include enable and disable subcommands', () => {
it('should include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(true);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('enable');
expect(subCommandNames).toContain('disable');
expect(subCommandNames).toContain('install');
expect(subCommandNames).toContain('uninstall');
});
});
describe('when enableExtensionReloading is false', () => {
it('should not include enable and disable subcommands', () => {
it('should not include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(false);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('when enableExtensionReloading is not provided', () => {
it('should not include enable and disable subcommands by default', () => {
it('should not include enable, disable, install, and uninstall subcommands by default', () => {
const command = extensionsCommand();
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('install', () => {
let installAction: SlashCommand['action'];
beforeEach(() => {
installAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'install',
)?.action;
expect(installAction).not.toBeNull();
mockContext.invocation!.name = 'install';
});
it('should show usage if no extension name is provided', async () => {
await installAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions install <source>',
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
it('should call installExtension and show success message', async () => {
const packageName = 'test-extension-package';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${packageName}" installed successfully.`,
},
expect.any(Number),
);
});
it('should show error message on installation failure', async () => {
const packageName = 'failed-extension';
const errorMessage = 'install failed';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockRejectedValue(new Error(errorMessage));
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
},
expect.any(Number),
);
});
it('should show error message for invalid source', async () => {
const invalidSource = 'a;b';
await installAction!(mockContext, invalidSource);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Invalid source: ${invalidSource}`,
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
});
describe('uninstall', () => {
let uninstallAction: SlashCommand['action'];
beforeEach(() => {
uninstallAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'uninstall',
)?.action;
expect(uninstallAction).not.toBeNull();
mockContext.invocation!.name = 'uninstall';
});
it('should show usage if no extension name is provided', async () => {
await uninstallAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions uninstall <extension-name>',
},
expect.any(Number),
);
expect(mockUninstallExtension).not.toHaveBeenCalled();
});
it('should call uninstallExtension and show success message', async () => {
const extensionName = 'test-extension';
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Uninstalling extension "${extensionName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${extensionName}" uninstalled successfully.`,
},
expect.any(Number),
);
});
it('should show error message on uninstallation failure', async () => {
const extensionName = 'failed-extension';
const errorMessage = 'uninstall failed';
mockUninstallExtension.mockRejectedValue(new Error(errorMessage));
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`,
},
expect.any(Number),
);
});
});
@@ -20,7 +20,10 @@ import {
} from './types.js';
import open from 'open';
import process from 'node:process';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { theme } from '../semantic-colors.js';
@@ -429,6 +432,135 @@ async function enableAction(context: CommandContext, args: string) {
}
}
async function installAction(context: CommandContext, args: string) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const source = args.trim();
if (!source) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Usage: /extensions install <source>`,
},
Date.now(),
);
return;
}
// Validate that the source is either a valid URL or a valid file path.
let isValid = false;
try {
// Check if it's a valid URL.
new URL(source);
isValid = true;
} catch {
// If not a URL, check for characters that are disallowed in file paths
// and could be used for command injection.
if (!/[;&|`'"]/.test(source)) {
isValid = true;
}
}
if (!isValid) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Invalid source: ${source}`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Installing extension from "${source}"...`,
},
Date.now(),
);
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Extension "${extension.name}" installed successfully.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to install extension from "${source}": ${getErrorMessage(
error,
)}`,
},
Date.now(),
);
}
}
async function uninstallAction(context: CommandContext, args: string) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const name = args.trim();
if (!name) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Usage: /extensions uninstall <extension-name>`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Uninstalling extension "${name}"...`,
},
Date.now(),
);
try {
await extensionLoader.uninstallExtension(name, false);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Extension "${name}" uninstalled successfully.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to uninstall extension "${name}": ${getErrorMessage(
error,
)}`,
},
Date.now(),
);
}
}
/**
* Exported for testing.
*/
@@ -505,6 +637,23 @@ const enableCommand: SlashCommand = {
completion: completeExtensionsAndScopes,
};
const installCommand: SlashCommand = {
name: 'install',
description: 'Install an extension from a git repo or local path',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: installAction,
};
const uninstallCommand: SlashCommand = {
name: 'uninstall',
description: 'Uninstall an extension',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: uninstallAction,
completion: completeExtensions,
};
const exploreExtensionsCommand: SlashCommand = {
name: 'explore',
description: 'Open extensions page in your browser',
@@ -526,7 +675,7 @@ export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
const conditionalCommands = enableExtensionReloading
? [disableCommand, enableCommand]
? [disableCommand, enableCommand, installCommand, uninstallCommand]
: [];
return {
name: 'extensions',
@@ -147,7 +147,7 @@ describe('hooksCommand', () => {
type: 'message',
messageType: 'info',
content:
'Hook system is not enabled. Enable it in settings with tools.enableHooks',
'Hook system is not enabled. Enable it in settings with hooks.enabled.',
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ async function panelAction(
type: 'message',
messageType: 'info',
content:
'Hook system is not enabled. Enable it in settings with tools.enableHooks',
'Hook system is not enabled. Enable it in settings with hooks.enabled.',
};
}
@@ -13,10 +13,10 @@ import {
getMCPServerStatus,
getMCPDiscoveryState,
DiscoveredMCPTool,
type MessageBus,
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { Type } from '@google/genai';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -37,6 +37,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus;
// Helper function to create a mock DiscoveredMCPTool
const createMockMCPTool = (
name: string,
@@ -50,8 +56,14 @@ const createMockMCPTool = (
} as unknown as CallableTool,
serverName,
name,
description || `Description for ${name}`,
{ type: Type.OBJECT, properties: {} },
description || 'Mock tool description',
{ type: 'object', properties: {} },
mockMessageBus,
undefined, // trust
undefined, // nameOverride
undefined, // cliConfig
undefined, // extensionName
undefined, // extensionId
);
describe('mcpCommand', () => {
@@ -0,0 +1,422 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { skillsCommand } from './skillsCommand.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext } from './types.js';
import type { Config, SkillDefinition } from '@google/gemini-cli-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('skillsCommand', () => {
let context: CommandContext;
beforeEach(() => {
vi.useFakeTimers();
const skills = [
{
name: 'skill1',
description: 'desc1',
location: '/loc1',
body: 'body1',
},
{
name: 'skill2',
description: 'desc2',
location: '/loc2',
body: 'body2',
},
];
context = createMockCommandContext({
services: {
config: {
getSkillManager: vi.fn().mockReturnValue({
getAllSkills: vi.fn().mockReturnValue(skills),
getSkills: vi.fn().mockReturnValue(skills),
getSkill: vi
.fn()
.mockImplementation(
(name: string) => skills.find((s) => s.name === name) ?? null,
),
}),
} as unknown as Config,
settings: {
merged: { skills: { disabled: [] } },
workspace: { path: '/workspace' },
setValue: vi.fn(),
} as unknown as LoadedSettings,
},
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should add a SKILLS_LIST item to UI with descriptions by default', async () => {
await skillsCommand.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.SKILLS_LIST,
skills: [
{
name: 'skill1',
description: 'desc1',
disabled: undefined,
location: '/loc1',
body: 'body1',
},
{
name: 'skill2',
description: 'desc2',
disabled: undefined,
location: '/loc2',
body: 'body2',
},
],
showDescriptions: true,
}),
expect.any(Number),
);
});
it('should list skills when "list" subcommand is used', async () => {
const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!;
await listCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.SKILLS_LIST,
skills: [
{
name: 'skill1',
description: 'desc1',
disabled: undefined,
location: '/loc1',
body: 'body1',
},
{
name: 'skill2',
description: 'desc2',
disabled: undefined,
location: '/loc2',
body: 'body2',
},
],
showDescriptions: true,
}),
expect.any(Number),
);
});
it('should disable descriptions if "nodesc" arg is provided to list', async () => {
const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!;
await listCmd.action!(context, 'nodesc');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
showDescriptions: false,
}),
expect.any(Number),
);
});
describe('disable/enable', () => {
beforeEach(() => {
context.services.settings.merged.skills = { disabled: [] };
(
context.services.settings as unknown as { workspace: { path: string } }
).workspace = {
path: '/workspace',
};
});
it('should disable a skill', async () => {
const disableCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'disable',
)!;
await disableCmd.action!(context, 'skill1');
expect(context.services.settings.setValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'skills.disabled',
['skill1'],
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Skill "skill1" disabled'),
}),
expect.any(Number),
);
});
it('should enable a skill', async () => {
const enableCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'enable',
)!;
context.services.settings.merged.skills = { disabled: ['skill1'] };
await enableCmd.action!(context, 'skill1');
expect(context.services.settings.setValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'skills.disabled',
[],
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Skill "skill1" enabled'),
}),
expect.any(Number),
);
});
it('should show error if skill not found during disable', async () => {
const disableCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'disable',
)!;
await disableCmd.action!(context, 'non-existent');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Skill "non-existent" not found.',
}),
expect.any(Number),
);
});
});
describe('reload', () => {
it('should reload skills successfully and show success message', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
// Make reload take some time so timer can fire
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
const actionPromise = reloadCmd.action!(context, '');
// Initially, no pending item (flicker prevention)
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
// Fast forward 100ms to trigger the pending item
await vi.advanceTimersByTimeAsync(100);
expect(context.ui.setPendingItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Reloading agent skills...',
}),
);
// Fast forward another 100ms (reload complete), but pending item should stay
await vi.advanceTimersByTimeAsync(100);
expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null);
// Fast forward to reach 500ms total
await vi.advanceTimersByTimeAsync(300);
await actionPromise;
expect(reloadSkillsMock).toHaveBeenCalled();
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully.',
}),
expect.any(Number),
);
});
it('should show new skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill1' },
{ name: 'skill2' },
{ name: 'skill3' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 newly available skill.',
}),
expect.any(Number),
);
});
it('should show removed skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill1' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 skill no longer available.',
}),
expect.any(Number),
);
});
it('should show both added and removed skills count after reload', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
const skillManager = context.services.config!.getSkillManager();
vi.mocked(skillManager.getSkills).mockReturnValue([
{ name: 'skill2' }, // skill1 removed, skill3 added
{ name: 'skill3' },
] as SkillDefinition[]);
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.',
}),
expect.any(Number),
);
});
it('should show error if configuration is missing', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
context.services.config = null;
await reloadCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Could not retrieve configuration.',
}),
expect.any(Number),
);
});
it('should show error if reload fails', async () => {
const reloadCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'reload',
)!;
const error = new Error('Reload failed');
const reloadSkillsMock = vi.fn().mockImplementation(async () => {
await new Promise((_, reject) => setTimeout(() => reject(error), 200));
});
// @ts-expect-error Mocking reloadSkills
context.services.config.reloadSkills = reloadSkillsMock;
const actionPromise = reloadCmd.action!(context, '');
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(400);
await actionPromise;
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to reload skills: Reload failed',
}),
expect.any(Number),
);
});
});
describe('completions', () => {
it('should provide completions for disable (only enabled skills)', async () => {
const disableCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'disable',
)!;
const skillManager = context.services.config!.getSkillManager();
const mockSkills = [
{
name: 'skill1',
description: 'desc1',
disabled: false,
location: '/loc1',
body: 'body1',
},
{
name: 'skill2',
description: 'desc2',
disabled: true,
location: '/loc2',
body: 'body2',
},
];
vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills);
vi.mocked(skillManager.getSkill).mockImplementation(
(name: string) => mockSkills.find((s) => s.name === name) ?? null,
);
const completions = await disableCmd.completion!(context, 'sk');
expect(completions).toEqual(['skill1']);
});
it('should provide completions for enable (only disabled skills)', async () => {
const enableCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'enable',
)!;
const skillManager = context.services.config!.getSkillManager();
const mockSkills = [
{
name: 'skill1',
description: 'desc1',
disabled: false,
location: '/loc1',
body: 'body1',
},
{
name: 'skill2',
description: 'desc2',
disabled: true,
location: '/loc2',
body: 'body2',
},
];
vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills);
vi.mocked(skillManager.getSkill).mockImplementation(
(name: string) => mockSkills.find((s) => s.name === name) ?? null,
);
const completions = await enableCmd.completion!(context, 'sk');
expect(completions).toEqual(['skill2']);
});
});
});
@@ -0,0 +1,321 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemSkillsList,
type HistoryItemInfo,
} from '../types.js';
import { SettingScope } from '../../config/settings.js';
async function listAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const subCommand = args.trim();
// Default to SHOWING descriptions. The user can hide them with 'nodesc'.
let useShowDescriptions = true;
if (subCommand === 'nodesc') {
useShowDescriptions = false;
}
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve skill manager.',
},
Date.now(),
);
return;
}
const skills = skillManager.getAllSkills();
const skillsListItem: HistoryItemSkillsList = {
type: MessageType.SKILLS_LIST,
skills: skills.map((skill) => ({
name: skill.name,
description: skill.description,
disabled: skill.disabled,
location: skill.location,
body: skill.body,
})),
showDescriptions: useShowDescriptions,
};
context.ui.addItem(skillsListItem, Date.now());
}
async function disableAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const skillName = args.trim();
if (!skillName) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Please provide a skill name to disable.',
},
Date.now(),
);
return;
}
const skillManager = context.services.config?.getSkillManager();
const skill = skillManager?.getSkill(skillName);
if (!skill) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Skill "${skillName}" not found.`,
},
Date.now(),
);
return;
}
const currentDisabled =
context.services.settings.merged.skills?.disabled ?? [];
if (currentDisabled.includes(skillName)) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" is already disabled.`,
},
Date.now(),
);
return;
}
const newDisabled = [...currentDisabled, skillName];
const scope = context.services.settings.workspace.path
? SettingScope.Workspace
: SettingScope.User;
context.services.settings.setValue(scope, 'skills.disabled', newDisabled);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
},
Date.now(),
);
}
async function enableAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const skillName = args.trim();
if (!skillName) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Please provide a skill name to enable.',
},
Date.now(),
);
return;
}
const currentDisabled =
context.services.settings.merged.skills?.disabled ?? [];
if (!currentDisabled.includes(skillName)) {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" is not disabled.`,
},
Date.now(),
);
return;
}
const newDisabled = currentDisabled.filter((name) => name !== skillName);
const scope = context.services.settings.workspace.path
? SettingScope.Workspace
: SettingScope.User;
context.services.settings.setValue(scope, 'skills.disabled', newDisabled);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`,
},
Date.now(),
);
}
async function reloadAction(
context: CommandContext,
): Promise<void | SlashCommandActionReturn> {
const config = context.services.config;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve configuration.',
},
Date.now(),
);
return;
}
const skillManager = config.getSkillManager();
const beforeNames = new Set(skillManager.getSkills().map((s) => s.name));
const startTime = Date.now();
let pendingItemSet = false;
const pendingTimeout = setTimeout(() => {
context.ui.setPendingItem({
type: MessageType.INFO,
text: 'Reloading agent skills...',
});
pendingItemSet = true;
}, 100);
try {
await config.reloadSkills();
clearTimeout(pendingTimeout);
if (pendingItemSet) {
// If we showed the pending item, make sure it stays for at least 500ms
// total to avoid a "flicker" where it appears and immediately disappears.
const elapsed = Date.now() - startTime;
const minVisibleDuration = 500;
if (elapsed < minVisibleDuration) {
await new Promise((resolve) =>
setTimeout(resolve, minVisibleDuration - elapsed),
);
}
context.ui.setPendingItem(null);
}
const afterSkills = skillManager.getSkills();
const afterNames = new Set(afterSkills.map((s) => s.name));
const added = afterSkills.filter((s) => !beforeNames.has(s.name));
const removedCount = [...beforeNames].filter(
(name) => !afterNames.has(name),
).length;
let successText = 'Agent skills reloaded successfully.';
const details: string[] = [];
if (added.length > 0) {
details.push(
`${added.length} newly available skill${added.length > 1 ? 's' : ''}`,
);
}
if (removedCount > 0) {
details.push(
`${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`,
);
}
if (details.length > 0) {
successText += ` ${details.join(' and ')}.`;
}
context.ui.addItem(
{
type: 'info',
text: successText,
icon: '✓ ',
color: 'green',
} as HistoryItemInfo,
Date.now(),
);
} catch (error) {
clearTimeout(pendingTimeout);
if (pendingItemSet) {
context.ui.setPendingItem(null);
}
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`,
},
Date.now(),
);
}
}
function disableCompletion(
context: CommandContext,
partialArg: string,
): string[] {
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
return [];
}
return skillManager
.getAllSkills()
.filter((s) => !s.disabled && s.name.startsWith(partialArg))
.map((s) => s.name);
}
function enableCompletion(
context: CommandContext,
partialArg: string,
): string[] {
const skillManager = context.services.config?.getSkillManager();
if (!skillManager) {
return [];
}
return skillManager
.getAllSkills()
.filter((s) => s.disabled && s.name.startsWith(partialArg))
.map((s) => s.name);
}
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable <name> | enable <name> | reload]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
{
name: 'list',
description: 'List available agent skills. Usage: /skills list [nodesc]',
kind: CommandKind.BUILT_IN,
action: listAction,
},
{
name: 'disable',
description: 'Disable a skill by name. Usage: /skills disable <name>',
kind: CommandKind.BUILT_IN,
action: disableAction,
completion: disableCompletion,
},
{
name: 'enable',
description:
'Enable a disabled skill by name. Usage: /skills enable <name>',
kind: CommandKind.BUILT_IN,
action: enableAction,
completion: enableCompletion,
},
{
name: 'reload',
description:
'Reload the list of discovered skills. Usage: /skills reload',
kind: CommandKind.BUILT_IN,
action: reloadAction,
},
],
action: listAction,
};
+6
View File
@@ -201,5 +201,11 @@ export interface SlashCommand {
partialArg: string,
) => Promise<string[]> | string[];
/**
* Whether to show the loading indicator while fetching completions.
* Defaults to true. Set to false for fast completions to avoid flicker.
*/
showCompletionLoading?: boolean;
subCommands?: SlashCommand[];
}
@@ -32,6 +32,7 @@ describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -52,6 +53,7 @@ describe('<AppHeader />', () => {
it('should render the banner with warning text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: 'There are capacity issues',
@@ -72,6 +74,7 @@ describe('<AppHeader />', () => {
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: '',
warningText: '',
@@ -91,6 +94,7 @@ describe('<AppHeader />', () => {
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -111,6 +115,7 @@ describe('<AppHeader />', () => {
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -131,6 +136,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -151,6 +157,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue({});
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -177,6 +184,7 @@ describe('<AppHeader />', () => {
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
@@ -36,6 +36,10 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
}));
vi.mock('./AutoAcceptIndicator.js', () => ({
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
}));
@@ -125,6 +129,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
errorCount: 0,
nightly: false,
isTrustedFolder: true,
activeHooks: [],
...overrides,
}) as UIState;
@@ -144,10 +149,16 @@ const createMockConfig = (overrides = {}) => ({
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
getMcpClientManager: vi.fn().mockImplementation(() => ({
getBlockedMcpServers: vi.fn(),
getMcpServers: vi.fn(),
})),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
getSkillManager: () => ({
getSkills: () => [],
}),
getMcpClientManager: () => ({
getMcpServers: () => ({}),
getBlockedMcpServers: () => [],
}),
...overrides,
});
@@ -335,6 +346,17 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ContextSummaryDisplay');
});
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('HookStatusDisplay');
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
+4 -35
View File
@@ -5,9 +5,9 @@
*/
import { useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
@@ -17,7 +17,6 @@ import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -43,7 +42,7 @@ export const Composer = () => {
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const { contextFileNames, showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator } = uiState;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
@@ -92,37 +91,7 @@ export const Composer = () => {
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={theme.status.error}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
</Text>
) : uiState.warningMessage ? (
<Text color={theme.status.warning}>{uiState.warningMessage}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
) : uiState.queueErrorMessage ? (
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
) : (
!settings.merged.ui?.hideContextSummary &&
!hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
/>
)
)}
<StatusDisplay hideContextSummary={hideContextSummary} />
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&

Some files were not shown because too many files have changed in this diff Show More